async-background 1.0.2 → 1.1.1

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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -0
  3. data/README.md +36 -15
  4. data/async-background.gemspec +13 -7
  5. data/lib/async/background/job.rb +75 -28
  6. data/lib/async/background/metrics.rb +41 -33
  7. data/lib/async/background/min_heap.rb +16 -12
  8. data/lib/async/background/queue/client.rb +11 -8
  9. data/lib/async/background/queue/options.rb +3 -3
  10. data/lib/async/background/queue/schema.rb +19 -15
  11. data/lib/async/background/queue/socket_notifier.rb +14 -4
  12. data/lib/async/background/queue/socket_waker.rb +139 -56
  13. data/lib/async/background/queue/sql.rb +31 -31
  14. data/lib/async/background/queue/store.rb +44 -45
  15. data/lib/async/background/runner/lifecycle.rb +131 -0
  16. data/lib/async/background/runner/queue_execution.rb +101 -57
  17. data/lib/async/background/runner/schedule.rb +20 -17
  18. data/lib/async/background/runner.rb +98 -77
  19. data/lib/async/background/runtime/notification.rb +42 -0
  20. data/lib/async/background/runtime/semaphore.rb +151 -0
  21. data/lib/async/background/runtime/task.rb +146 -0
  22. data/lib/async/background/runtime/task_group.rb +81 -0
  23. data/lib/async/background/runtime.rb +246 -0
  24. data/lib/async/background/scheduler.rb +145 -0
  25. data/lib/async/background/version.rb +1 -1
  26. data/lib/async/background/web/app.rb +43 -40
  27. data/lib/async/background/web/assets/app.css +174 -0
  28. data/lib/async/background/web/assets/app.js +486 -0
  29. data/lib/async/background/web/assets/index.html +29 -0
  30. data/lib/async/background/web/assets.rb +4 -697
  31. data/lib/async/background/web/auth.rb +3 -3
  32. data/lib/async/background/web/configuration.rb +40 -93
  33. data/lib/async/background/web/cursor.rb +22 -26
  34. data/lib/async/background/web/event_hub.rb +12 -7
  35. data/lib/async/background/web/metrics_reader.rb +28 -36
  36. data/lib/async/background/web/response.rb +39 -51
  37. data/lib/async/background/web/serializer.rb +23 -63
  38. data/lib/async/background/web/snapshot.rb +35 -81
  39. data/lib/async/background/web/sql.rb +63 -69
  40. data/lib/async/background/web/stream.rb +52 -32
  41. data/lib/async/background/web.rb +1 -1
  42. data/lib/async/background.rb +1 -2
  43. metadata +30 -22
@@ -1,25 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'async/barrier'
4
-
3
+ require_relative 'runtime'
4
+ require_relative 'runner/lifecycle'
5
5
  require_relative 'runner/queue_execution'
6
6
  require_relative 'runner/schedule'
7
7
 
8
8
  module Async
9
9
  module Background
10
- class ConfigError < StandardError; end
10
+ class ConfigError < Error; end
11
11
 
12
12
  DEFAULT_TIMEOUT = 30
13
13
  MIN_SLEEP_TIME = 0.1
14
14
  MAX_JITTER = 5
15
15
  QUEUE_POLL_INTERVAL = 5
16
16
  MIN_QUEUE_WAIT = 0.001
17
+ QUEUE_ERROR_BACKOFF = 0.5
18
+ SERVICE_SHUTDOWN_GRACE = 5
19
+ SERVICE_CANCEL_GRACE = 2
20
+ DRAIN_GRACE = 30
21
+ JOB_CANCEL_GRACE = 5
17
22
 
18
23
  class Runner
19
24
  include Clock
25
+ include Lifecycle
20
26
  include QueueExecution
21
27
  include Schedule
22
28
 
29
+ Settings = Data.define(
30
+ :config_path, :job_count, :queue_socket_dir, :queue_db_path, :queue_mmap,
31
+ :drain_timeout, :metrics_shm_path
32
+ )
33
+ private_constant :Settings
34
+
23
35
  attr_reader :logger,
24
36
  :semaphore,
25
37
  :heap,
@@ -27,44 +39,59 @@ module Async
27
39
  :total_workers,
28
40
  :shutdown,
29
41
  :metrics,
30
- :queue_store
31
-
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
- def initialize(
36
- config_path: nil,
37
- job_count: 2,
38
- worker_index:,
39
- total_workers:,
40
- queue_socket_dir: nil,
41
- queue_db_path: nil,
42
- queue_mmap: true,
43
- metrics_shm_path: Metrics.default_shm_path
44
- )
42
+ :queue_store,
43
+ :jobs,
44
+ :services
45
+
46
+ def initialize(worker_index:, total_workers:, **options)
47
+ settings = Settings.new(**default_settings.merge(options))
48
+ initialize_worker(worker_index, total_workers, settings)
49
+ end
50
+
51
+ def default_settings
52
+ {
53
+ config_path: nil,
54
+ job_count: 2,
55
+ queue_socket_dir: nil,
56
+ queue_db_path: nil,
57
+ queue_mmap: true,
58
+ drain_timeout: DRAIN_GRACE,
59
+ metrics_shm_path: Metrics.default_shm_path
60
+ }
61
+ end
62
+
63
+ def initialize_worker(worker_index, total_workers, settings)
64
+ config_path = settings.config_path
65
+ job_count = settings.job_count
45
66
  @logger = Console.logger
46
67
  @worker_index = worker_index
47
68
  @total_workers = total_workers
48
69
  @running = true
49
- @shutdown = ::Async::Condition.new
50
- @metrics = Metrics.new(worker_index: worker_index, total_workers: total_workers, shm_path: metrics_shm_path)
70
+ @drain_timeout = settings.drain_timeout
71
+ @shutdown = Runtime::Notification.new
72
+ @metrics = Metrics.new(worker_index: worker_index, total_workers: total_workers, shm_path: settings.metrics_shm_path)
51
73
  logger.info { "Async::Background worker_index=#{worker_index}/#{total_workers}, job_count=#{job_count}" }
52
74
 
53
- @drain_barrier = ::Async::Barrier.new
54
- @semaphore = ::Async::Semaphore.new(job_count, parent: @drain_barrier)
75
+ @jobs = Runtime::TaskGroup.new(on_error: error_handler, on_release: method(:job_released))
76
+ @services = Runtime::TaskGroup.new(on_error: error_handler)
77
+ @semaphore = Runtime::Semaphore.new(job_count)
55
78
  @heap = config_path.nil? ? MinHeap.new : build_heap(config_path)
56
- setup_queue(queue_socket_dir, queue_db_path, queue_mmap)
79
+ setup_queue(settings.queue_socket_dir, settings.queue_db_path, settings.queue_mmap)
57
80
  validate_work_source!(config_path)
58
81
  end
82
+ private :default_settings, :initialize_worker
59
83
 
60
84
  def run
61
- Async do |task|
85
+ Runtime.scheduler!
86
+ warn_unsafe_timeouts
87
+
88
+ Runtime.with_error_handler(error_handler) do
62
89
  setup_signal_handlers
63
- start_signal_watcher(task)
64
- start_queue_listener(task) if @listen_queue
90
+ start_signal_watcher
91
+ start_queue_listener if @listen_queue
65
92
 
66
- scheduler_loop(task)
67
- drain_and_close_queue
93
+ scheduler_loop
94
+ shutdown_gracefully
68
95
  end
69
96
  end
70
97
 
@@ -72,26 +99,21 @@ module Async
72
99
  return unless @running
73
100
 
74
101
  @running = false
75
- logger.info { 'Async::Background: stopping gracefully' }
76
- shutdown.signal
77
- @queue_waker&.signal
102
+ wake_signal_watcher
78
103
  end
79
104
 
80
105
  def running? = @running
81
106
 
82
107
  private
83
108
 
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.
109
+ def scheduler_loop
88
110
  return shutdown.wait if heap.empty? && @listen_queue
89
111
 
90
112
  while running?
91
113
  entry = heap.peek
92
114
  break unless entry
93
115
 
94
- wait_for_next_entry(task, entry)
116
+ wait_for_next_entry(entry)
95
117
  break unless running?
96
118
 
97
119
  dispatch_due_entries
@@ -104,9 +126,9 @@ module Async
104
126
  raise ConfigError, 'Runner requires config_path or queue_socket_dir'
105
127
  end
106
128
 
107
- def wait_for_next_entry(task, entry)
129
+ def wait_for_next_entry(entry)
108
130
  wait = [entry.next_run_at - monotonic_now, MIN_SLEEP_TIME].max
109
- wait_with_shutdown(task, wait)
131
+ wait_with_shutdown(wait)
110
132
  end
111
133
 
112
134
  def dispatch_due_entries
@@ -131,66 +153,65 @@ module Async
131
153
 
132
154
  def execute_entry(entry)
133
155
  entry.running = true
134
- semaphore.async do |job_task|
156
+ spawn_job do |job_task|
135
157
  run_job(job_task, entry)
136
158
  ensure
137
159
  entry.running = false
138
160
  end
139
161
  end
140
162
 
163
+ def spawn_job
164
+ @jobs.spawn(name: 'job') { |task| semaphore.acquire { yield(task) } }
165
+ end
166
+
167
+ def job_released(_task)
168
+ return unless @queue_saturated
169
+
170
+ @queue_waker&.signal
171
+ end
172
+
141
173
  def run_job(job_task, entry)
142
174
  metrics_started = false
175
+ metrics_started = start_job_metrics(entry)
176
+ execute_entry_job(job_task, entry)
177
+ rescue Runtime::TimeoutError
178
+ record_job_timeout(entry)
179
+ rescue StandardError => e
180
+ record_job_failure(entry, e)
181
+ ensure
182
+ metrics.job_stopped(entry) if metrics_started
183
+ end
184
+
185
+ def start_job_metrics(entry)
143
186
  metrics.job_started(entry)
144
- metrics_started = true
187
+ true
188
+ end
189
+
190
+ def execute_entry_job(job_task, entry)
145
191
  started_at = monotonic_now
146
192
  job_task.with_timeout(entry.timeout) { entry.job_class.perform_now }
193
+ record_job_success(entry, monotonic_now - started_at)
194
+ end
147
195
 
148
- duration = monotonic_now - started_at
196
+ def record_job_success(entry, duration)
149
197
  metrics.job_succeeded(entry, duration)
150
198
  logger.info('Async::Background') { "#{entry.name}: completed in #{duration.round(2)}s" }
151
- rescue ::Async::TimeoutError
152
- metrics.job_timed_out(entry)
153
- logger.error('Async::Background') { "#{entry.name}: timed out after #{entry.timeout}s" }
154
- rescue StandardError => error
155
- metrics.job_failed(entry, error)
156
- logger.error('Async::Background') {
157
- "#{entry.name}: #{error.class} #{error.message}\n#{error.backtrace.join("\n")}"
158
- }
159
- ensure
160
- metrics.job_stopped(entry) if metrics_started
161
199
  end
162
200
 
163
- def setup_signal_handlers
164
- @signal_r, @signal_w = IO.pipe
165
-
166
- %w[INT TERM].each do |signal|
167
- Signal.trap(signal) do
168
- @running = false
169
- @signal_w.write_nonblock('.') rescue nil
170
- end
171
- end
201
+ def record_job_timeout(entry)
202
+ metrics.job_timed_out(entry)
203
+ logger.error('Async::Background') { "#{entry.name}: timed out after #{entry.timeout}s" }
172
204
  end
173
205
 
174
- def start_signal_watcher(task)
175
- task.async(transient: true) do
176
- while running?
177
- @signal_r.wait_readable
178
- @signal_r.read_nonblock(256) rescue nil
179
- shutdown.signal
180
- @queue_waker&.signal
181
- end
206
+ def record_job_failure(entry, error)
207
+ metrics.job_failed(entry, error)
208
+ logger.error('Async::Background') do
209
+ "#{entry.name}: #{error.class} #{error.message}\n#{error.backtrace.join("\n")}"
182
210
  end
183
211
  end
184
212
 
185
- def wait_with_shutdown(task, duration)
186
- task.with_timeout(duration) { shutdown.wait }
187
- rescue ::Async::TimeoutError
188
- end
189
-
190
- def drain_and_close_queue
191
- @drain_barrier.wait
192
- @queue_store&.close
193
- @queue_waker&.close
213
+ def wait_with_shutdown(duration)
214
+ shutdown.wait(duration)
194
215
  end
195
216
  end
196
217
  end
@@ -0,0 +1,42 @@
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
35
+ @waiters = []
36
+ pending.each { |waiter| Runtime.wake_dequeued(waiter, self) }
37
+ nil
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module Background
5
+ module Runtime
6
+ class Semaphore
7
+ attr_reader :limit, :available
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 waiting = @waiting.size
18
+
19
+ def acquire
20
+ wait
21
+ return @available unless block_given?
22
+
23
+ begin
24
+ yield
25
+ ensure
26
+ release
27
+ end
28
+ end
29
+
30
+ def release
31
+ while (node = @waiting.shift)
32
+ node.grant!
33
+ return @available if node.resume
34
+ end
35
+
36
+ @available += 1
37
+ @available
38
+ end
39
+
40
+ private
41
+
42
+ def wait
43
+ return (@available -= 1) if @available.positive?
44
+
45
+ task = Runtime.current_task
46
+ node = FiberNode.new(Fiber.current, self)
47
+ @waiting.push(node)
48
+ task&.enter_block(node.waiter)
49
+ wait_for_grant(node, task)
50
+ ensure
51
+ release_wait_state(node, task) if node
52
+ end
53
+
54
+ def wait_for_grant(node, task)
55
+ until node.granted?
56
+ node.suspend
57
+ abandon_wait!(node, task) if task&.cancelled?
58
+ end
59
+ abandon_wait!(node, task) if task&.cancelled?
60
+ end
61
+
62
+ def release_wait_state(node, task)
63
+ task&.exit_block
64
+ @waiting.remove(node) unless node.granted? || node.list.nil?
65
+ end
66
+
67
+ def abandon_wait!(node, task)
68
+ node.granted? ? release : @waiting.remove(node)
69
+ task.raise_if_cancelled!
70
+ end
71
+
72
+ class WaitList
73
+ attr_reader :size
74
+
75
+ def initialize
76
+ @head = @tail = nil
77
+ @size = 0
78
+ end
79
+
80
+ def first = @head
81
+ def empty? = @head.nil?
82
+
83
+ def push(node)
84
+ raise ArgumentError, 'node already queued' if node.list
85
+
86
+ node.list = self
87
+ node.prev = @tail
88
+ node.next = nil
89
+ @tail ? (@tail.next = node) : (@head = node)
90
+ @tail = node
91
+ @size += 1
92
+ node
93
+ end
94
+
95
+ def shift
96
+ node = @head or return nil
97
+
98
+ remove(node)
99
+ end
100
+
101
+ def remove(node)
102
+ return nil unless node.list.equal?(self)
103
+
104
+ node.prev ? (node.prev.next = node.next) : (@head = node.next)
105
+ node.next ? (node.next.prev = node.prev) : (@tail = node.prev)
106
+ node.list = node.prev = node.next = nil
107
+ @size -= 1
108
+ node
109
+ end
110
+ end
111
+
112
+ class FiberNode
113
+ attr_accessor :list, :prev, :next
114
+ attr_reader :fiber, :waiter, :blocker
115
+
116
+ def initialize(fiber, blocker)
117
+ @fiber = fiber
118
+ @blocker = blocker
119
+ @list = @prev = @next = nil
120
+ @granted = false
121
+ @scheduler = Runtime.scheduler!
122
+ @waiter = { fiber: fiber, scheduler: @scheduler, ready: false, blocker: blocker, queued: true }
123
+ end
124
+
125
+ def granted? = @granted
126
+
127
+ def grant!
128
+ @granted = true
129
+ @waiter[:ready] = true
130
+ @waiter[:queued] = false
131
+ self
132
+ end
133
+
134
+ def suspend
135
+ @scheduler.block(@blocker, nil)
136
+ end
137
+
138
+ def resume
139
+ fiber = @fiber
140
+ return false unless fiber.alive?
141
+
142
+ @scheduler.unblock(@blocker, fiber)
143
+ true
144
+ end
145
+ end
146
+
147
+ private_constant :WaitList, :FiberNode
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,146 @@
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 { run_scheduled_task { takes_task ? yield(self) : yield } }
33
+
34
+ @fiber ||= scheduled if scheduled.is_a?(Fiber)
35
+ self
36
+ end
37
+
38
+ def wait(timeout = nil)
39
+ unless @done
40
+ deadline = Runtime.deadline_for(timeout)
41
+ raise TimeoutError, 'task did not finish in time' unless join(deadline)
42
+ end
43
+
44
+ raise @error if @error
45
+
46
+ @result
47
+ end
48
+
49
+ def with_timeout(duration, &block)
50
+ Runtime.with_timeout(duration, &block)
51
+ end
52
+
53
+ def stop
54
+ return false if @done
55
+
56
+ @cancelled = true
57
+
58
+ return true if interrupt_fiber
59
+ return true if release_blocker
60
+
61
+ false
62
+ end
63
+
64
+ def cancelled? = @cancelled
65
+ def finished? = @done
66
+ def waiting = @waiters ? @waiters.size : 0
67
+
68
+ def raise_if_cancelled!
69
+ raise Cancel, 'task stopped' if @cancelled
70
+ end
71
+
72
+ def enter_block(waiter)
73
+ @blocker = waiter
74
+ end
75
+
76
+ def exit_block
77
+ @blocker = nil
78
+ end
79
+
80
+ private
81
+
82
+ def run_scheduled_task
83
+ adopt_fiber(Fiber.current)
84
+ previous = Runtime.current_task
85
+ Runtime.current_task = self
86
+ complete(yield, nil)
87
+ rescue Cancel
88
+ complete(nil, nil)
89
+ rescue Exception => e # rubocop:disable Lint/RescueException
90
+ complete(nil, e)
91
+ ensure
92
+ Runtime.current_task = previous
93
+ end
94
+
95
+ def adopt_fiber(fiber)
96
+ @adopt_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
140
+ @waiters = []
141
+ pending.each { |waiter| Runtime.wake_dequeued(waiter, self) }
142
+ end
143
+ end
144
+ end
145
+ end
146
+ 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 => e
76
+ Runtime.report_error(task, e, @on_error)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end