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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +81 -0
- data/README.md +36 -15
- data/async-background.gemspec +13 -7
- data/lib/async/background/job.rb +75 -28
- data/lib/async/background/metrics.rb +41 -33
- data/lib/async/background/min_heap.rb +16 -12
- data/lib/async/background/queue/client.rb +11 -8
- data/lib/async/background/queue/options.rb +3 -3
- data/lib/async/background/queue/schema.rb +19 -15
- data/lib/async/background/queue/socket_notifier.rb +14 -4
- data/lib/async/background/queue/socket_waker.rb +139 -56
- data/lib/async/background/queue/sql.rb +31 -31
- data/lib/async/background/queue/store.rb +44 -45
- data/lib/async/background/runner/lifecycle.rb +131 -0
- data/lib/async/background/runner/queue_execution.rb +101 -57
- data/lib/async/background/runner/schedule.rb +20 -17
- data/lib/async/background/runner.rb +98 -77
- data/lib/async/background/runtime/notification.rb +42 -0
- data/lib/async/background/runtime/semaphore.rb +151 -0
- data/lib/async/background/runtime/task.rb +146 -0
- data/lib/async/background/runtime/task_group.rb +81 -0
- data/lib/async/background/runtime.rb +246 -0
- data/lib/async/background/scheduler.rb +145 -0
- data/lib/async/background/version.rb +1 -1
- data/lib/async/background/web/app.rb +43 -40
- data/lib/async/background/web/assets/app.css +174 -0
- data/lib/async/background/web/assets/app.js +486 -0
- data/lib/async/background/web/assets/index.html +29 -0
- data/lib/async/background/web/assets.rb +4 -697
- data/lib/async/background/web/auth.rb +3 -3
- data/lib/async/background/web/configuration.rb +40 -93
- data/lib/async/background/web/cursor.rb +22 -26
- data/lib/async/background/web/event_hub.rb +12 -7
- data/lib/async/background/web/metrics_reader.rb +28 -36
- 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 +35 -81
- data/lib/async/background/web/sql.rb +63 -69
- data/lib/async/background/web/stream.rb +52 -32
- data/lib/async/background/web.rb +1 -1
- data/lib/async/background.rb +1 -2
- 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
|
-
@
|
|
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
|
|
30
|
-
@queue_waker.start_accept_loop
|
|
34
|
+
def start_queue_listener
|
|
35
|
+
@queue_waker.start_accept_loop
|
|
31
36
|
|
|
32
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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(
|
|
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
|
|
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 =>
|
|
115
|
-
raise ConfigError, "invalid queue options: #{
|
|
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
|
-
|
|
172
|
+
return if job.nil?
|
|
135
173
|
|
|
136
|
-
|
|
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!(
|
|
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')
|
|
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!(
|
|
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')
|
|
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(
|
|
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
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 =>
|
|
97
|
-
raise ConfigError, "[#{name}] #{
|
|
99
|
+
rescue ConfigError => e
|
|
100
|
+
raise ConfigError, "[#{name}] #{e.message}"
|
|
98
101
|
end
|
|
99
102
|
|
|
100
103
|
def parse_interval(name, value)
|
|
101
|
-
|
|
102
|
-
interval = interval.to_i
|
|
103
|
-
raise ConfigError, "[#{name}] 'every' must be > 0" unless interval.positive?
|
|
104
|
+
return if value.nil?
|
|
104
105
|
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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 =>
|
|
118
|
-
raise ConfigError, "[#{name}] #{
|
|
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)
|