async-background 1.0.2 → 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.
@@ -59,12 +59,15 @@ module Async
59
59
  end
60
60
 
61
61
  def build_entries(schedule, now)
62
- schedule.each_with_object(MinHeap.new) do |(name, config), heap|
63
- next unless assigned_worker(config, name) == worker_index
62
+ schedule
63
+ .filter_map { |name, config| entry_for(name, config, now) }
64
+ .each_with_object(MinHeap.new) { |entry, heap| heap.push(entry) }
65
+ end
64
66
 
65
- task = build_task_config(name, config)
66
- heap.push(build_entry(name, task, now))
67
- end
67
+ def entry_for(name, config, now)
68
+ return unless assigned_worker(config, name) == worker_index
69
+
70
+ build_entry(name, build_task_config(name, config), now)
68
71
  end
69
72
 
70
73
  def assigned_worker(config, name)
@@ -98,18 +101,18 @@ module Async
98
101
  end
99
102
 
100
103
  def parse_interval(name, value)
101
- value&.then do |interval|
102
- interval = interval.to_i
103
- raise ConfigError, "[#{name}] 'every' must be > 0" unless interval.positive?
104
+ return if value.nil?
104
105
 
105
- interval
106
- end
106
+ interval = value.to_i
107
+ raise ConfigError, "[#{name}] 'every' must be > 0" unless interval.positive?
108
+
109
+ interval
107
110
  end
108
111
 
109
112
  def parse_cron(name, value)
110
- value&.then do |expression|
111
- Fugit::Cron.new(expression) || raise(ConfigError, "[#{name}] invalid cron: #{expression}")
112
- end
113
+ return if value.nil?
114
+
115
+ Fugit::Cron.new(value) || raise(ConfigError, "[#{name}] invalid cron: #{value}")
113
116
  end
114
117
 
115
118
  def parse_timeout(name, config)
@@ -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,31 +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
53
+ @drain_timeout = drain_timeout
54
+ @shutdown = Runtime::Notification.new
50
55
  @metrics = Metrics.new(worker_index: worker_index, total_workers: total_workers, shm_path: metrics_shm_path)
51
56
  logger.info { "Async::Background worker_index=#{worker_index}/#{total_workers}, job_count=#{job_count}" }
52
57
 
53
- @drain_barrier = ::Async::Barrier.new
54
- @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)
55
61
  @heap = config_path.nil? ? MinHeap.new : build_heap(config_path)
56
62
  setup_queue(queue_socket_dir, queue_db_path, queue_mmap)
57
63
  validate_work_source!(config_path)
58
64
  end
59
65
 
60
66
  def run
61
- Async do |task|
67
+ Runtime.scheduler!
68
+ warn_unsafe_timeouts
69
+
70
+ Runtime.with_error_handler(error_handler) do
62
71
  setup_signal_handlers
63
- start_signal_watcher(task)
64
- start_queue_listener(task) if @listen_queue
72
+ start_signal_watcher
73
+ start_queue_listener if @listen_queue
65
74
 
66
- scheduler_loop(task)
67
- drain_and_close_queue
75
+ scheduler_loop
76
+ shutdown_gracefully
68
77
  end
69
78
  end
70
79
 
@@ -72,26 +81,21 @@ module Async
72
81
  return unless @running
73
82
 
74
83
  @running = false
75
- logger.info { 'Async::Background: stopping gracefully' }
76
- shutdown.signal
77
- @queue_waker&.signal
84
+ wake_signal_watcher
78
85
  end
79
86
 
80
87
  def running? = @running
81
88
 
82
89
  private
83
90
 
84
- def scheduler_loop(task)
85
- # Queue-only workers have no heap entry to sleep on. Keep the runner
86
- # alive until #stop / SIGTERM wakes this condition; the queue listener
87
- # continues independently in its own Async task.
91
+ def scheduler_loop
88
92
  return shutdown.wait if heap.empty? && @listen_queue
89
93
 
90
94
  while running?
91
95
  entry = heap.peek
92
96
  break unless entry
93
97
 
94
- wait_for_next_entry(task, entry)
98
+ wait_for_next_entry(entry)
95
99
  break unless running?
96
100
 
97
101
  dispatch_due_entries
@@ -104,9 +108,9 @@ module Async
104
108
  raise ConfigError, 'Runner requires config_path or queue_socket_dir'
105
109
  end
106
110
 
107
- def wait_for_next_entry(task, entry)
111
+ def wait_for_next_entry(entry)
108
112
  wait = [entry.next_run_at - monotonic_now, MIN_SLEEP_TIME].max
109
- wait_with_shutdown(task, wait)
113
+ wait_with_shutdown(wait)
110
114
  end
111
115
 
112
116
  def dispatch_due_entries
@@ -131,13 +135,23 @@ module Async
131
135
 
132
136
  def execute_entry(entry)
133
137
  entry.running = true
134
- semaphore.async do |job_task|
138
+ spawn_job do |job_task|
135
139
  run_job(job_task, entry)
136
140
  ensure
137
141
  entry.running = false
138
142
  end
139
143
  end
140
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
+
141
155
  def run_job(job_task, entry)
142
156
  metrics_started = false
143
157
  metrics.job_started(entry)
@@ -148,7 +162,7 @@ module Async
148
162
  duration = monotonic_now - started_at
149
163
  metrics.job_succeeded(entry, duration)
150
164
  logger.info('Async::Background') { "#{entry.name}: completed in #{duration.round(2)}s" }
151
- rescue ::Async::TimeoutError
165
+ rescue Runtime::TimeoutError
152
166
  metrics.job_timed_out(entry)
153
167
  logger.error('Async::Background') { "#{entry.name}: timed out after #{entry.timeout}s" }
154
168
  rescue StandardError => error
@@ -171,26 +185,102 @@ module Async
171
185
  end
172
186
  end
173
187
 
174
- def start_signal_watcher(task)
175
- task.async(transient: true) do
188
+ def start_signal_watcher
189
+ @services.spawn(name: 'signal-watcher') do
176
190
  while running?
177
- @signal_r.wait_readable
178
- @signal_r.read_nonblock(256) rescue nil
179
- shutdown.signal
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
180
203
  @queue_waker&.signal
181
204
  end
182
205
  end
183
206
  end
184
207
 
185
- def wait_with_shutdown(task, duration)
186
- task.with_timeout(duration) { shutdown.wait }
187
- 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
188
218
  end
189
219
 
190
- def drain_and_close_queue
191
- @drain_barrier.wait
192
- @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
193
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
194
284
  end
195
285
  end
196
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