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
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module Background
5
+ class Runner
6
+ module Lifecycle
7
+ private
8
+
9
+ def setup_signal_handlers
10
+ @signal_r, @signal_w = IO.pipe
11
+
12
+ %w[INT TERM].each do |signal|
13
+ Signal.trap(signal) { stop_from_signal }
14
+ end
15
+ end
16
+
17
+ def stop_from_signal
18
+ @running = false
19
+ @signal_w.write_nonblock('.')
20
+ rescue StandardError
21
+ nil
22
+ end
23
+
24
+ def start_signal_watcher
25
+ @services.spawn(name: 'signal-watcher') { watch_signals }
26
+ end
27
+
28
+ def watch_signals
29
+ while running?
30
+ result = read_signal
31
+ next if result == :wait
32
+ break unless result
33
+
34
+ logger.info { 'Async::Background: stopping gracefully' } unless running?
35
+ shutdown.signal_all
36
+ @queue_waker&.signal
37
+ end
38
+ end
39
+
40
+ def read_signal
41
+ @signal_r.wait_readable
42
+ @signal_r.read_nonblock(256)
43
+ rescue IO::WaitReadable
44
+ :wait
45
+ rescue IOError, Errno::EBADF
46
+ false
47
+ end
48
+
49
+ def error_handler
50
+ @error_handler ||= build_error_handler
51
+ end
52
+
53
+ def build_error_handler
54
+ log = logger
55
+ lambda do |task, error|
56
+ log.error('Async::Background') do
57
+ "task #{task&.name || 'unnamed'} died: #{error.class} #{error.message}\n" \
58
+ "#{Array(error.backtrace).join("\n")}"
59
+ end
60
+ end
61
+ end
62
+
63
+ def warn_unsafe_timeouts
64
+ return if Runtime.native_timeouts?
65
+
66
+ logger.warn('Async::Background') do
67
+ "#{Fiber.scheduler.class} does not implement #timeout_after; job timeouts " \
68
+ 'fall back to stdlib Timeout and may interrupt an unrelated fiber'
69
+ end
70
+ end
71
+
72
+ def wake_signal_watcher
73
+ @signal_w&.write_nonblock('.')
74
+ rescue StandardError
75
+ nil
76
+ end
77
+
78
+ def shutdown_gracefully
79
+ wake_services
80
+ drain_jobs
81
+ stop_services
82
+ close_queue
83
+ close_signal_pipe
84
+ end
85
+
86
+ def wake_services
87
+ shutdown.signal_all
88
+ wake_signal_watcher
89
+ @queue_waker&.signal
90
+ end
91
+
92
+ def drain_jobs
93
+ return if @jobs.empty?
94
+
95
+ logger.info { "Async::Background: draining #{@jobs.size} in-flight job(s)" }
96
+ @jobs.wait(@drain_timeout)
97
+ rescue Runtime::TimeoutError
98
+ cancel_draining_jobs
99
+ end
100
+
101
+ def cancel_draining_jobs
102
+ logger.warn('Async::Background') do
103
+ "#{@jobs.size} job(s) still running after #{@drain_timeout}s, cancelling"
104
+ end
105
+ @jobs.stop_all(JOB_CANCEL_GRACE)
106
+ end
107
+
108
+ def stop_services
109
+ return if @services.empty?
110
+
111
+ @services.wait(SERVICE_SHUTDOWN_GRACE)
112
+ rescue Runtime::TimeoutError
113
+ logger.warn('Async::Background') { 'service tasks did not exit in time, cancelling' }
114
+ @services.stop_all(SERVICE_CANCEL_GRACE)
115
+ end
116
+
117
+ def close_queue
118
+ @queue_waker&.close
119
+ @queue_store&.close
120
+ end
121
+
122
+ def close_signal_pipe
123
+ @signal_r&.close
124
+ @signal_w&.close
125
+ rescue IOError
126
+ nil
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -6,10 +6,15 @@ module Async
6
6
  # Queue-only lifecycle. Keeping it separate from recurring scheduling makes
7
7
  # the different delivery guarantees visible without adding runtime objects.
8
8
  module QueueExecution
9
+ QueueRun = Data.define(:job, :class_name, :claim_token, :options, :started_at, :job_class)
10
+ QueueFailure = Data.define(:job, :options, :message, :error, :duration, :timeout, :backtrace)
11
+ private_constant :QueueRun, :QueueFailure
12
+
9
13
  private
10
14
 
11
15
  def setup_queue(queue_socket_dir, queue_db_path, queue_mmap)
12
- @listen_queue = !!queue_socket_dir && !isolated_worker?
16
+ @queue_saturated = false
17
+ @listen_queue = !queue_socket_dir.nil? && !isolated_worker?
13
18
  return unless @listen_queue
14
19
 
15
20
  require_relative '../queue/client'
@@ -18,7 +23,7 @@ module Async
18
23
 
19
24
  @queue_store = Queue::Store.new(
20
25
  path: queue_db_path || Queue::Store.default_path,
21
- options: {mmap: queue_mmap}
26
+ options: { mmap: queue_mmap }
22
27
  )
23
28
 
24
29
  @queue_waker = Queue::SocketWaker.new(queue_socket_path(queue_socket_dir))
@@ -26,20 +31,36 @@ module Async
26
31
  recover_queue_jobs
27
32
  end
28
33
 
29
- def start_queue_listener(task)
30
- @queue_waker.start_accept_loop(task)
34
+ def start_queue_listener
35
+ @queue_waker.start_accept_loop
31
36
 
32
- task.async do
37
+ @services.spawn(name: 'queue-listener') do
33
38
  logger.info { "Async::Background queue: listening on worker #{worker_index}" }
34
39
 
40
+ failures = 0
41
+
35
42
  while running?
36
- @queue_waker.wait(timeout: next_wait_timeout)
37
- dispatch_available_queue_jobs
43
+ begin
44
+ @queue_waker.wait(timeout: next_wait_timeout)
45
+ break unless running?
46
+
47
+ dispatch_available_queue_jobs
48
+ failures = 0
49
+ rescue StandardError => e
50
+ failures += 1
51
+ backoff = [QUEUE_ERROR_BACKOFF * failures, QUEUE_POLL_INTERVAL].min
52
+ logger.error('Async::Background') do
53
+ "queue listener: #{e.class} #{e.message}; retrying in #{backoff}s"
54
+ end
55
+ shutdown.wait(backoff)
56
+ end
38
57
  end
39
58
  end
40
59
  end
41
60
 
42
61
  def next_wait_timeout
62
+ return QUEUE_POLL_INTERVAL if @queue_saturated
63
+
43
64
  next_due = @queue_store.next_pending_run_at
44
65
  return QUEUE_POLL_INTERVAL unless next_due
45
66
 
@@ -50,49 +71,56 @@ module Async
50
71
  end
51
72
 
52
73
  def run_queue_job(job_task, job)
53
- class_name = job[:class_name]
54
- claim_token = job[:claim_token]
55
- options = nil
56
- started_at = nil
74
+ context = QueueRun.new(job, job[:class_name], job[:claim_token], nil, nil, nil)
75
+ context = resolve_queue_run(context)
57
76
  metrics_started = false
77
+ metrics_started = start_queue_metrics(context)
78
+ return unless metrics_started
79
+
80
+ context = context.with(started_at: monotonic_now)
81
+ perform_queue_run(job_task, context)
82
+ complete_queue_job!(context)
83
+ rescue ConfigError => e
84
+ record_invalid_queue_job!(context, e)
85
+ rescue Runtime::TimeoutError => e
86
+ handle_queue_failure(queue_failure(context, e, timeout: true))
87
+ rescue StandardError => e
88
+ handle_queue_failure(queue_failure(context, e, timeout: false))
89
+ ensure
90
+ metrics.job_stopped(nil) if metrics_started
91
+ end
58
92
 
59
- klass = resolve_job_class(class_name)
93
+ def resolve_queue_run(context)
94
+ job, class_name, = context.deconstruct
95
+ job_class = resolve_job_class(class_name)
60
96
  options = parse_job_options(job[:options])
61
- return unless start_queue_job!(job, class_name, claim_token)
97
+ context.with(options: options, job_class: job_class)
98
+ end
99
+
100
+ def start_queue_metrics(context)
101
+ job, class_name, claim_token, = context.deconstruct
102
+ return false unless start_queue_job!(job, class_name, claim_token)
62
103
 
63
104
  metrics.job_started(nil)
64
- metrics_started = true
65
- started_at = monotonic_now
66
- job_task.with_timeout(options.timeout) { klass.perform_now(*job[:args]) }
67
-
68
- complete_queue_job!(job, class_name, claim_token, started_at)
69
- rescue ConfigError => error
70
- record_invalid_queue_job!(job, class_name, claim_token, error)
71
- rescue ::Async::TimeoutError => error
72
- handle_queue_failure(
73
- job,
74
- options,
75
- "timed out after #{options&.timeout}s",
76
- error: error,
77
- duration: started_at && (monotonic_now - started_at),
78
- timeout: true,
79
- backtrace: nil
80
- )
81
- rescue StandardError => error
82
- handle_queue_failure(
83
- job,
84
- options,
85
- "#{error.class} #{error.message}",
86
- error: error,
87
- duration: started_at && (monotonic_now - started_at),
88
- timeout: false,
89
- backtrace: error.backtrace
90
- )
91
- ensure
92
- metrics.job_stopped(nil) if metrics_started
105
+ true
106
+ end
107
+
108
+ def perform_queue_run(job_task, context)
109
+ job, _class_name, _claim_token, options, _started_at, job_class = context.deconstruct
110
+ job_task.with_timeout(options.timeout) do
111
+ job_class.perform_now(*job[:args])
112
+ end
113
+ end
114
+
115
+ def queue_failure(context, error, timeout:)
116
+ job, _class_name, _claim_token, options, started_at = context.deconstruct
117
+ message = timeout ? "timed out after #{options&.timeout}s" : "#{error.class} #{error.message}"
118
+ duration = started_at && (monotonic_now - started_at)
119
+ QueueFailure.new(job, options, message, error, duration, timeout, timeout ? nil : error.backtrace)
93
120
  end
94
121
 
95
- def handle_queue_failure(job, options, message, error:, duration:, timeout:, backtrace:)
122
+ def handle_queue_failure(failure)
123
+ job, options, message, error, duration, timeout, backtrace = failure.deconstruct
96
124
  class_name = job[:class_name]
97
125
  result = @queue_store.retry_or_fail(
98
126
  job[:id],
@@ -105,14 +133,17 @@ module Async
105
133
  return log_stale_queue_failure(job, class_name, timeout) unless result
106
134
 
107
135
  timeout ? metrics.job_timed_out(nil) : metrics.job_failed(nil, error)
108
- result == :retried ? log_queue_retry(job, class_name, message, options) :
136
+ if result == :retried
137
+ log_queue_retry(job, class_name, message, options)
138
+ else
109
139
  log_queue_failure(class_name, message, backtrace)
140
+ end
110
141
  end
111
142
 
112
143
  def parse_job_options(raw)
113
144
  Job::Options.new(**(raw || {}))
114
- rescue ArgumentError, TypeError => error
115
- raise ConfigError, "invalid queue options: #{error.message}"
145
+ rescue ArgumentError, TypeError => e
146
+ raise ConfigError, "invalid queue options: #{e.message}"
116
147
  end
117
148
 
118
149
  def isolated_worker?
@@ -129,36 +160,47 @@ module Async
129
160
  end
130
161
 
131
162
  def dispatch_available_queue_jobs
163
+ @queue_saturated = false
164
+
132
165
  while running?
166
+ if @jobs.size >= @semaphore.limit
167
+ @queue_saturated = true
168
+ return
169
+ end
170
+
133
171
  job = @queue_store.fetch(worker_index)
134
- break unless job
172
+ return if job.nil?
135
173
 
136
- semaphore.async { |job_task| run_queue_job(job_task, job) }
174
+ spawn_job { |job_task| run_queue_job(job_task, job) }
137
175
  end
138
176
  end
139
177
 
140
178
  def start_queue_job!(job, class_name, claim_token)
141
179
  return true if @queue_store.mark_started!(job[:id], claim_token: claim_token)
142
180
 
143
- logger.warn('Async::Background') {
181
+ logger.warn('Async::Background') do
144
182
  "queue(#{class_name}): lost lease before start for job #{job[:id]}, ignored"
145
- }
183
+ end
146
184
  false
147
185
  end
148
186
 
149
- def complete_queue_job!(job, class_name, claim_token, started_at)
187
+ def complete_queue_job!(context)
188
+ job, class_name, claim_token, _options, started_at = context.deconstruct
150
189
  duration = monotonic_now - started_at
151
190
  if @queue_store.complete(job[:id], claim_token: claim_token, duration_ms: duration_ms(duration))
152
191
  metrics.job_succeeded(nil, duration)
153
- logger.info('Async::Background') { "queue(#{class_name}): completed in #{duration.round(2)}s" }
192
+ logger.info('Async::Background') do
193
+ "queue(#{class_name}): completed in #{duration.round(2)}s"
194
+ end
154
195
  else
155
- logger.warn('Async::Background') {
196
+ logger.warn('Async::Background') do
156
197
  "queue(#{class_name}): complete: stale lease for job #{job[:id]}, ignored"
157
- }
198
+ end
158
199
  end
159
200
  end
160
201
 
161
- def record_invalid_queue_job!(job, class_name, claim_token, error)
202
+ def record_invalid_queue_job!(context, error)
203
+ job, class_name, claim_token, = context.deconstruct
162
204
  recorded = @queue_store.fail(
163
205
  job[:id],
164
206
  claim_token: claim_token,
@@ -166,7 +208,9 @@ module Async
166
208
  error_message: error.message
167
209
  )
168
210
  metrics.job_failed(nil, error) if recorded
169
- logger.error('Async::Background') { "queue(#{class_name}): #{error.class} #{error.message}" }
211
+ logger.error('Async::Background') do
212
+ "queue(#{class_name}): #{error.class} #{error.message}"
213
+ end
170
214
  end
171
215
 
172
216
  def log_stale_queue_failure(job, class_name, timeout)
@@ -176,7 +220,7 @@ module Async
176
220
  end
177
221
  end
178
222
 
179
- def log_queue_retry(job, class_name, message, options)
223
+ def log_queue_retry(_job, class_name, message, options)
180
224
  @queue_waker&.signal
181
225
  logger.warn('Async::Background') do
182
226
  "queue(#{class_name}): #{message}; retry #{options&.next_attempt}/#{options&.retry}"
@@ -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)
@@ -93,29 +96,29 @@ module Async
93
96
 
94
97
  def resolve_scheduled_job(name, class_name)
95
98
  resolve_job_class(class_name)
96
- rescue ConfigError => error
97
- raise ConfigError, "[#{name}] #{error.message}"
99
+ rescue ConfigError => e
100
+ raise ConfigError, "[#{name}] #{e.message}"
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)
116
119
  Job::Options.new(timeout: config.fetch('timeout', DEFAULT_TIMEOUT)).timeout
117
- rescue ArgumentError, TypeError => error
118
- raise ConfigError, "[#{name}] #{error.message}"
120
+ rescue ArgumentError, TypeError => e
121
+ raise ConfigError, "[#{name}] #{e.message}"
119
122
  end
120
123
 
121
124
  def validate_schedule_frequency!(name, interval, cron)