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,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'timeout'
|
|
4
|
+
|
|
5
|
+
module Async
|
|
6
|
+
module Background
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
module Runtime
|
|
10
|
+
class Error < Background::Error; end
|
|
11
|
+
class SchedulerRequired < Error; end
|
|
12
|
+
class TimeoutError < Error; end
|
|
13
|
+
class Cancel < Exception; end
|
|
14
|
+
class Deadline < Exception; end
|
|
15
|
+
|
|
16
|
+
UNSET = Object.new
|
|
17
|
+
|
|
18
|
+
CURRENT_TASK_KEY = :async_background_current_task
|
|
19
|
+
WAITER_KEY = :async_background_waiter
|
|
20
|
+
|
|
21
|
+
DEADLINE_MESSAGE = 'execution expired'
|
|
22
|
+
|
|
23
|
+
NO_SCHEDULER_MESSAGE = <<~MESSAGE
|
|
24
|
+
Async::Background requires an active Fiber scheduler.
|
|
25
|
+
|
|
26
|
+
Install one in the host process before calling this, for example:
|
|
27
|
+
|
|
28
|
+
require "async/background/scheduler"
|
|
29
|
+
Async::Background::Scheduler.run { runner.run }
|
|
30
|
+
|
|
31
|
+
or install one yourself:
|
|
32
|
+
|
|
33
|
+
Fiber.set_scheduler(Itsi::Scheduler.new) # itsi-scheduler
|
|
34
|
+
Async { runner.run } # async / falcon
|
|
35
|
+
MESSAGE
|
|
36
|
+
|
|
37
|
+
MISSING_TIMEOUT_HOOK_WARNING = <<~MESSAGE
|
|
38
|
+
Async::Background: %s does not implement #timeout_after.
|
|
39
|
+
|
|
40
|
+
Falling back to stdlib Timeout, which uses Thread#raise and can deliver
|
|
41
|
+
the timeout to an unrelated fiber. Job timeouts are therefore not safe
|
|
42
|
+
on this scheduler. Use a scheduler that implements #timeout_after
|
|
43
|
+
(async, itsi-scheduler) or run jobs with `timeout: nil`.
|
|
44
|
+
MESSAGE
|
|
45
|
+
|
|
46
|
+
@error_handler = nil
|
|
47
|
+
@warned_schedulers = {}
|
|
48
|
+
|
|
49
|
+
module_function
|
|
50
|
+
|
|
51
|
+
module FiberLocals
|
|
52
|
+
def current_task
|
|
53
|
+
fiber_local(CURRENT_TASK_KEY)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def current_task=(task)
|
|
57
|
+
set_fiber_local(CURRENT_TASK_KEY, task)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def fiber_local(key)
|
|
61
|
+
Fiber[key]
|
|
62
|
+
rescue ArgumentError, FiberError
|
|
63
|
+
fiber_local_fallback[key]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def set_fiber_local(key, value)
|
|
67
|
+
Fiber[key] = value
|
|
68
|
+
rescue ArgumentError, FiberError
|
|
69
|
+
fiber_local_fallback[key] = value
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def clear_fiber_local(key, expected)
|
|
73
|
+
current = fiber_local(key)
|
|
74
|
+
set_fiber_local(key, nil) if current.equal?(expected)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def fiber_local_fallback
|
|
78
|
+
fiber = Fiber.current
|
|
79
|
+
store = fiber.instance_variable_get(:@async_background_locals)
|
|
80
|
+
store || fiber.instance_variable_set(:@async_background_locals, {})
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
extend FiberLocals
|
|
84
|
+
|
|
85
|
+
def spawn(name: nil, on_error: UNSET, &block)
|
|
86
|
+
Task.spawn(name: name, on_error: on_error, &block)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def scheduler
|
|
90
|
+
Fiber.scheduler
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def scheduler!
|
|
94
|
+
Fiber.scheduler or raise SchedulerRequired, NO_SCHEDULER_MESSAGE
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def native_timeouts?(target = Fiber.scheduler)
|
|
98
|
+
!target.nil? && target.respond_to?(:timeout_after)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def build_waiter(blocker)
|
|
102
|
+
existing = fiber_local(WAITER_KEY)
|
|
103
|
+
if existing&.[](:blocker)
|
|
104
|
+
raise Error, "waiter already parked on #{existing[:blocker].class}; " \
|
|
105
|
+
'a fiber may only park in one place at a time'
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
waiter = {
|
|
109
|
+
fiber: Fiber.current,
|
|
110
|
+
scheduler: scheduler!,
|
|
111
|
+
ready: false,
|
|
112
|
+
queued: false,
|
|
113
|
+
blocker: blocker
|
|
114
|
+
}
|
|
115
|
+
set_fiber_local(WAITER_KEY, waiter)
|
|
116
|
+
waiter
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def park(blocker, waiter, deadline = nil)
|
|
120
|
+
scheduler = waiter[:scheduler]
|
|
121
|
+
task = current_task
|
|
122
|
+
task&.enter_block(waiter)
|
|
123
|
+
park_until_ready(scheduler, blocker, waiter, deadline, task) { yield }
|
|
124
|
+
ensure
|
|
125
|
+
waiter[:blocker] = nil
|
|
126
|
+
clear_fiber_local(WAITER_KEY, waiter)
|
|
127
|
+
task&.exit_block
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def park_until_ready(scheduler, blocker, waiter, deadline, task)
|
|
131
|
+
until waiter[:ready] || yield
|
|
132
|
+
return false if wait_for_blocker(scheduler, blocker, deadline) == :expired
|
|
133
|
+
|
|
134
|
+
task&.raise_if_cancelled!
|
|
135
|
+
end
|
|
136
|
+
true
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def wait_for_blocker(scheduler, blocker, deadline)
|
|
140
|
+
return scheduler.block(blocker, nil) unless deadline
|
|
141
|
+
|
|
142
|
+
remaining = deadline - monotonic_now
|
|
143
|
+
return :expired if remaining <= 0
|
|
144
|
+
|
|
145
|
+
scheduler.block(blocker, remaining)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def with_waiter(blocker, waiters)
|
|
149
|
+
waiter = build_waiter(blocker)
|
|
150
|
+
waiter[:queued] = true
|
|
151
|
+
waiters << waiter
|
|
152
|
+
yield waiter
|
|
153
|
+
ensure
|
|
154
|
+
waiters.delete(waiter) if waiter[:queued]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def wake_dequeued(waiter, blocker)
|
|
158
|
+
waiter[:queued] = false
|
|
159
|
+
wake(waiter, blocker)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def wake(waiter, blocker)
|
|
163
|
+
return false if waiter[:ready]
|
|
164
|
+
|
|
165
|
+
waiter[:ready] = true
|
|
166
|
+
fiber = waiter[:fiber]
|
|
167
|
+
return false unless fiber.alive?
|
|
168
|
+
|
|
169
|
+
waiter[:scheduler].unblock(blocker, fiber)
|
|
170
|
+
true
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def monotonic_now
|
|
174
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def deadline_for(timeout)
|
|
178
|
+
return nil if timeout.nil?
|
|
179
|
+
|
|
180
|
+
seconds = Float(timeout)
|
|
181
|
+
raise ArgumentError, 'timeout must be non-negative and finite' unless seconds.finite? && seconds >= 0
|
|
182
|
+
|
|
183
|
+
monotonic_now + seconds
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
attr_writer :error_handler
|
|
187
|
+
attr_reader :error_handler
|
|
188
|
+
module_function :error_handler=, :error_handler
|
|
189
|
+
|
|
190
|
+
def with_error_handler(handler)
|
|
191
|
+
previous = @error_handler
|
|
192
|
+
@error_handler = handler
|
|
193
|
+
yield
|
|
194
|
+
ensure
|
|
195
|
+
@error_handler = previous
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def report_error(task, error, handler = UNSET)
|
|
199
|
+
handler = @error_handler if UNSET.equal?(handler)
|
|
200
|
+
return false unless handler
|
|
201
|
+
|
|
202
|
+
handler.call(task, error)
|
|
203
|
+
true
|
|
204
|
+
rescue StandardError
|
|
205
|
+
false
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def with_timeout(duration, on_timeout: UNSET, &block)
|
|
209
|
+
return yield if duration.nil?
|
|
210
|
+
|
|
211
|
+
seconds = Float(duration)
|
|
212
|
+
raise ArgumentError, 'timeout must be positive and finite' unless seconds.finite? && seconds.positive?
|
|
213
|
+
|
|
214
|
+
begin
|
|
215
|
+
arm_deadline(seconds, &block)
|
|
216
|
+
rescue Deadline => e
|
|
217
|
+
raise TimeoutError, e.message if UNSET.equal?(on_timeout)
|
|
218
|
+
|
|
219
|
+
on_timeout
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def arm_deadline(seconds, &block)
|
|
224
|
+
target = scheduler!
|
|
225
|
+
|
|
226
|
+
return target.timeout_after(seconds, Deadline, DEADLINE_MESSAGE, &block) if native_timeouts?(target)
|
|
227
|
+
|
|
228
|
+
warn_missing_timeout_hook(target)
|
|
229
|
+
::Timeout.timeout(seconds, Deadline, DEADLINE_MESSAGE, &block)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def warn_missing_timeout_hook(target)
|
|
233
|
+
key = target.class
|
|
234
|
+
return if @warned_schedulers[key]
|
|
235
|
+
|
|
236
|
+
@warned_schedulers[key] = true
|
|
237
|
+
warn(format(MISSING_TIMEOUT_HOOK_WARNING, key))
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
require_relative 'runtime/notification'
|
|
244
|
+
require_relative 'runtime/semaphore'
|
|
245
|
+
require_relative 'runtime/task'
|
|
246
|
+
require_relative 'runtime/task_group'
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'runtime'
|
|
4
|
+
|
|
5
|
+
module Async
|
|
6
|
+
module Background
|
|
7
|
+
module Scheduler
|
|
8
|
+
ENV_KEY = 'ASYNC_BACKGROUND_SCHEDULER'
|
|
9
|
+
THREAD_ENV_KEY = 'ASYNC_BACKGROUND_SCHEDULER_THREAD'
|
|
10
|
+
KNOWN = %i[async itsi].freeze
|
|
11
|
+
|
|
12
|
+
class UnknownScheduler < ArgumentError; end
|
|
13
|
+
class Unavailable < Background::Error; end
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def installed? = !Fiber.scheduler.nil?
|
|
18
|
+
|
|
19
|
+
def current
|
|
20
|
+
return nil unless installed?
|
|
21
|
+
|
|
22
|
+
Fiber.scheduler.class.name
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def preload!(kind = nil)
|
|
26
|
+
case resolve(kind)
|
|
27
|
+
when :async then require 'async'
|
|
28
|
+
when :itsi then require 'itsi/scheduler'
|
|
29
|
+
end
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def run(kind = nil, &block)
|
|
34
|
+
raise ArgumentError, 'block required' unless block
|
|
35
|
+
return yield if installed?
|
|
36
|
+
|
|
37
|
+
case resolve(kind)
|
|
38
|
+
when :async then run_async(&block)
|
|
39
|
+
when :itsi then run_itsi(&block)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def resolve(kind = nil)
|
|
44
|
+
name = (kind || ENV.fetch(ENV_KEY, 'auto')).to_s.downcase
|
|
45
|
+
return name.to_sym if KNOWN.include?(name.to_sym)
|
|
46
|
+
return detect if name == 'auto'
|
|
47
|
+
|
|
48
|
+
raise UnknownScheduler, "unknown scheduler #{name.inspect}, expected one of: #{KNOWN.join(', ')}, auto"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def detect
|
|
52
|
+
return :async if defined?(::Async::Scheduler)
|
|
53
|
+
return :itsi if defined?(::Itsi::Scheduler)
|
|
54
|
+
|
|
55
|
+
return :async if available?('async')
|
|
56
|
+
return :itsi if available?('itsi/scheduler')
|
|
57
|
+
|
|
58
|
+
raise Unavailable, 'no fiber scheduler available: add `async` or `itsi-scheduler` to your bundle'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def available?(feature)
|
|
62
|
+
!Gem.find_files(feature).empty? || !Gem.find_files("#{feature}.rb").empty?
|
|
63
|
+
rescue StandardError
|
|
64
|
+
try_require(feature)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def try_require(feature)
|
|
68
|
+
require feature
|
|
69
|
+
true
|
|
70
|
+
rescue LoadError
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def run_async(&block)
|
|
75
|
+
require 'async'
|
|
76
|
+
|
|
77
|
+
result = nil
|
|
78
|
+
send(:Async) { result = yield }
|
|
79
|
+
result
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def run_itsi(&block)
|
|
83
|
+
require 'itsi/scheduler'
|
|
84
|
+
|
|
85
|
+
return run_on_thread(::Itsi::Scheduler, &block) if threaded?
|
|
86
|
+
|
|
87
|
+
run_on_current_thread(::Itsi::Scheduler.new, &block)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def threaded?
|
|
91
|
+
%w[1 true yes].include?(ENV.fetch(THREAD_ENV_KEY, '').to_s.downcase)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def run_on_current_thread(scheduler, &block)
|
|
95
|
+
result = nil
|
|
96
|
+
failure = nil
|
|
97
|
+
finished = false
|
|
98
|
+
|
|
99
|
+
previous = Fiber.scheduler
|
|
100
|
+
Fiber.set_scheduler(scheduler)
|
|
101
|
+
|
|
102
|
+
begin
|
|
103
|
+
Fiber.schedule do
|
|
104
|
+
result = yield
|
|
105
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
106
|
+
failure = e
|
|
107
|
+
ensure
|
|
108
|
+
finished = true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
scheduler.run if scheduler.respond_to?(:run)
|
|
112
|
+
ensure
|
|
113
|
+
Fiber.set_scheduler(previous)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
raise failure if failure
|
|
117
|
+
unless finished
|
|
118
|
+
raise Unavailable,
|
|
119
|
+
"#{scheduler.class} did not run the scheduled fiber to completion on close; " \
|
|
120
|
+
"set #{THREAD_ENV_KEY}=1 to fall back to a dedicated scheduler thread"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
result
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def run_on_thread(scheduler_class, &block)
|
|
127
|
+
result = nil
|
|
128
|
+
failure = nil
|
|
129
|
+
|
|
130
|
+
thread = Thread.new do
|
|
131
|
+
Fiber.set_scheduler(scheduler_class.new)
|
|
132
|
+
Fiber.schedule do
|
|
133
|
+
result = yield
|
|
134
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
135
|
+
failure = e
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
thread.join
|
|
139
|
+
raise failure if failure
|
|
140
|
+
|
|
141
|
+
result
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -32,6 +32,23 @@ module Async
|
|
|
32
32
|
|
|
33
33
|
private
|
|
34
34
|
|
|
35
|
+
LIST_ROUTES = {
|
|
36
|
+
executing: [:executing, :executing, nil].freeze,
|
|
37
|
+
claimed: [:claimed, :claimed, nil].freeze,
|
|
38
|
+
done: %i[recent_done done finished_cursor].freeze,
|
|
39
|
+
failed: %i[recent_failed failed finished_cursor].freeze,
|
|
40
|
+
pending: %i[pending pending pending_cursor].freeze
|
|
41
|
+
}.freeze
|
|
42
|
+
STATIC_ROUTES = {
|
|
43
|
+
index: :index_response,
|
|
44
|
+
javascript: :javascript_response,
|
|
45
|
+
stylesheet: :stylesheet_response,
|
|
46
|
+
overview: :overview_response,
|
|
47
|
+
metrics: :metrics_response,
|
|
48
|
+
config: :config_response,
|
|
49
|
+
stream: :stream_response
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
35
52
|
def handle(env, head:)
|
|
36
53
|
return Response.unauthorized unless @auth.authorized?(env)
|
|
37
54
|
|
|
@@ -43,15 +60,15 @@ module Async
|
|
|
43
60
|
end
|
|
44
61
|
|
|
45
62
|
dispatch(route, env)
|
|
46
|
-
rescue RequestError =>
|
|
47
|
-
Response.bad_request(
|
|
63
|
+
rescue RequestError => e
|
|
64
|
+
Response.bad_request(e.message)
|
|
48
65
|
rescue UnavailableError, ClosedError
|
|
49
66
|
Response.unavailable
|
|
50
|
-
rescue StandardError =>
|
|
67
|
+
rescue StandardError => e
|
|
51
68
|
# Do not turn internal class names, paths or database errors into an
|
|
52
69
|
# unauthenticated information disclosure channel — but do surface
|
|
53
70
|
# them to the operator via the configured logger.
|
|
54
|
-
log_internal_error(env,
|
|
71
|
+
log_internal_error(env, e)
|
|
55
72
|
Response.internal_error
|
|
56
73
|
end
|
|
57
74
|
|
|
@@ -68,51 +85,37 @@ module Async
|
|
|
68
85
|
end
|
|
69
86
|
|
|
70
87
|
def dispatch(route, env)
|
|
71
|
-
|
|
72
|
-
when :index then Response.html(Assets.render_index(@config))
|
|
73
|
-
when :javascript then Response.javascript(Assets::JS)
|
|
74
|
-
when :stylesheet then Response.stylesheet(Assets::CSS)
|
|
75
|
-
when :overview then overview_response
|
|
76
|
-
when :executing then in_flight_response(:executing, env)
|
|
77
|
-
when :claimed then in_flight_response(:claimed, env)
|
|
78
|
-
when :done then terminal_response(:done, env)
|
|
79
|
-
when :failed then terminal_response(:failed, env)
|
|
80
|
-
when :pending then pending_response(env)
|
|
81
|
-
when :metrics then metrics_response
|
|
82
|
-
when :config then config_response
|
|
83
|
-
when :stream then stream_response
|
|
84
|
-
else Response.not_found
|
|
85
|
-
end
|
|
86
|
-
end
|
|
88
|
+
return list_response(route, env) if LIST_ROUTES.key?(route)
|
|
87
89
|
|
|
88
|
-
|
|
89
|
-
Response.
|
|
90
|
+
handler = STATIC_ROUTES[route]
|
|
91
|
+
handler ? send(handler) : Response.not_found
|
|
90
92
|
end
|
|
91
93
|
|
|
92
|
-
def
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
payload = kind == :executing ? @serializer.executing(rows) : @serializer.claimed(rows)
|
|
96
|
-
Response.json({items: payload})
|
|
97
|
-
end
|
|
94
|
+
def index_response = Response.html(Assets.render_index(@config))
|
|
95
|
+
|
|
96
|
+
def javascript_response = Response.javascript(Assets::JS)
|
|
98
97
|
|
|
99
|
-
def
|
|
98
|
+
def stylesheet_response = Response.stylesheet(Assets::CSS)
|
|
99
|
+
|
|
100
|
+
def list_response(route, env)
|
|
101
|
+
reader, shape, cursor_kind = LIST_ROUTES.fetch(route)
|
|
100
102
|
request = Request.new(env, @config)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
@snapshot.
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
|
|
104
|
+
unless cursor_kind
|
|
105
|
+
rows = @snapshot.public_send(reader, limit: request.limit)
|
|
106
|
+
return Response.json({ items: @serializer.public_send(shape, rows) })
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
rows = @snapshot.public_send(reader, limit: request.limit, cursor: request.public_send(cursor_kind))
|
|
110
|
+
Response.json(@serializer.public_send(shape, rows))
|
|
106
111
|
end
|
|
107
112
|
|
|
108
|
-
def
|
|
109
|
-
|
|
110
|
-
rows = @snapshot.pending(limit: request.limit, cursor: request.pending_cursor)
|
|
111
|
-
Response.json(@serializer.pending(rows))
|
|
113
|
+
def overview_response
|
|
114
|
+
Response.json(@serializer.overview(@snapshot.overview, metrics_payload))
|
|
112
115
|
end
|
|
113
116
|
|
|
114
117
|
def metrics_response
|
|
115
|
-
Response.json(metrics_payload ||
|
|
118
|
+
Response.json(metrics_payload || MetricsReader::UNAVAILABLE)
|
|
116
119
|
end
|
|
117
120
|
|
|
118
121
|
def metrics_payload
|
|
@@ -148,7 +151,7 @@ module Async
|
|
|
148
151
|
|
|
149
152
|
def log_internal_error(env, error)
|
|
150
153
|
@logger&.error(
|
|
151
|
-
|
|
154
|
+
'[async-background-web] internal error on ' \
|
|
152
155
|
"#{env['REQUEST_METHOD']} #{env['PATH_INFO']}: " \
|
|
153
156
|
"#{error.class}: #{error.message}"
|
|
154
157
|
)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #0f1115;
|
|
3
|
+
--panel: #161a20;
|
|
4
|
+
--panel-soft: #1c2128;
|
|
5
|
+
--border: #2a313b;
|
|
6
|
+
--text: #e6e8ec;
|
|
7
|
+
--text-dim: #98a2b3;
|
|
8
|
+
--accent: #4f8ef7;
|
|
9
|
+
--green: #4ade80;
|
|
10
|
+
--amber: #fbbf24;
|
|
11
|
+
--red: #f87171;
|
|
12
|
+
--blue: #60a5fa;
|
|
13
|
+
--gray: #94a3b8;
|
|
14
|
+
}
|
|
15
|
+
* { box-sizing: border-box; }
|
|
16
|
+
body {
|
|
17
|
+
margin: 0;
|
|
18
|
+
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
19
|
+
background: var(--bg);
|
|
20
|
+
color: var(--text);
|
|
21
|
+
}
|
|
22
|
+
header {
|
|
23
|
+
display: flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
gap: 16px;
|
|
26
|
+
padding: 14px 24px;
|
|
27
|
+
border-bottom: 1px solid var(--border);
|
|
28
|
+
background: var(--panel);
|
|
29
|
+
}
|
|
30
|
+
header h1 { font-size: 16px; margin: 0; font-weight: 600; }
|
|
31
|
+
header .meta { color: var(--text-dim); font-size: 12px; }
|
|
32
|
+
header .status-dot {
|
|
33
|
+
width: 8px; height: 8px; border-radius: 50%;
|
|
34
|
+
background: var(--gray);
|
|
35
|
+
display: inline-block; margin-right: 6px;
|
|
36
|
+
}
|
|
37
|
+
header .status-dot.ok { background: var(--green); }
|
|
38
|
+
header .status-dot.stale { background: var(--amber); }
|
|
39
|
+
header .status-dot.error { background: var(--red); }
|
|
40
|
+
main { padding: 16px 24px; }
|
|
41
|
+
.counts {
|
|
42
|
+
display: grid;
|
|
43
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
44
|
+
gap: 12px;
|
|
45
|
+
margin-bottom: 20px;
|
|
46
|
+
}
|
|
47
|
+
.count-card {
|
|
48
|
+
background: var(--panel);
|
|
49
|
+
border: 1px solid var(--border);
|
|
50
|
+
border-radius: 8px;
|
|
51
|
+
padding: 12px 14px;
|
|
52
|
+
}
|
|
53
|
+
.count-card .label { color: var(--text-dim); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
|
|
54
|
+
.count-card .value { font-size: 24px; font-weight: 600; margin-top: 4px; font-variant-numeric: tabular-nums; }
|
|
55
|
+
.count-card.executing .value { color: var(--blue); }
|
|
56
|
+
.count-card.claimed .value { color: var(--amber); }
|
|
57
|
+
.count-card.pending .value { color: var(--text); }
|
|
58
|
+
.count-card.done .value { color: var(--green); }
|
|
59
|
+
.count-card.failed .value { color: var(--red); }
|
|
60
|
+
.totals {
|
|
61
|
+
display: grid;
|
|
62
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
63
|
+
gap: 12px;
|
|
64
|
+
margin-bottom: 20px;
|
|
65
|
+
}
|
|
66
|
+
.total-card {
|
|
67
|
+
background: var(--panel-soft);
|
|
68
|
+
border: 1px solid var(--border);
|
|
69
|
+
border-radius: 8px;
|
|
70
|
+
padding: 10px 12px;
|
|
71
|
+
}
|
|
72
|
+
.total-card .label { color: var(--text-dim); font-size: 11px; }
|
|
73
|
+
.total-card .value { font-variant-numeric: tabular-nums; font-size: 18px; margin-top: 2px; }
|
|
74
|
+
nav.tabs {
|
|
75
|
+
display: flex;
|
|
76
|
+
gap: 2px;
|
|
77
|
+
border-bottom: 1px solid var(--border);
|
|
78
|
+
margin-bottom: 12px;
|
|
79
|
+
flex-wrap: wrap;
|
|
80
|
+
}
|
|
81
|
+
nav.tabs button {
|
|
82
|
+
background: transparent;
|
|
83
|
+
color: var(--text-dim);
|
|
84
|
+
border: none;
|
|
85
|
+
border-bottom: 2px solid transparent;
|
|
86
|
+
padding: 10px 14px;
|
|
87
|
+
font: inherit;
|
|
88
|
+
cursor: pointer;
|
|
89
|
+
border-radius: 0;
|
|
90
|
+
}
|
|
91
|
+
nav.tabs button:hover { color: var(--text); }
|
|
92
|
+
nav.tabs button.active {
|
|
93
|
+
color: var(--text);
|
|
94
|
+
border-bottom-color: var(--accent);
|
|
95
|
+
}
|
|
96
|
+
nav.tabs button .badge {
|
|
97
|
+
display: inline-block;
|
|
98
|
+
background: var(--panel-soft);
|
|
99
|
+
color: var(--text-dim);
|
|
100
|
+
border-radius: 10px;
|
|
101
|
+
padding: 1px 8px;
|
|
102
|
+
font-size: 11px;
|
|
103
|
+
margin-left: 6px;
|
|
104
|
+
font-variant-numeric: tabular-nums;
|
|
105
|
+
}
|
|
106
|
+
table {
|
|
107
|
+
width: 100%;
|
|
108
|
+
border-collapse: collapse;
|
|
109
|
+
background: var(--panel);
|
|
110
|
+
border: 1px solid var(--border);
|
|
111
|
+
border-radius: 8px;
|
|
112
|
+
overflow: hidden;
|
|
113
|
+
}
|
|
114
|
+
th, td {
|
|
115
|
+
text-align: left;
|
|
116
|
+
padding: 9px 12px;
|
|
117
|
+
border-bottom: 1px solid var(--border);
|
|
118
|
+
vertical-align: top;
|
|
119
|
+
font-variant-numeric: tabular-nums;
|
|
120
|
+
}
|
|
121
|
+
tbody tr:last-child td { border-bottom: none; }
|
|
122
|
+
th {
|
|
123
|
+
background: var(--panel-soft);
|
|
124
|
+
color: var(--text-dim);
|
|
125
|
+
font-weight: 500;
|
|
126
|
+
font-size: 11px;
|
|
127
|
+
text-transform: uppercase;
|
|
128
|
+
letter-spacing: .04em;
|
|
129
|
+
}
|
|
130
|
+
td.dim { color: var(--text-dim); }
|
|
131
|
+
td.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
|
132
|
+
.empty {
|
|
133
|
+
padding: 32px;
|
|
134
|
+
text-align: center;
|
|
135
|
+
color: var(--text-dim);
|
|
136
|
+
background: var(--panel);
|
|
137
|
+
border: 1px solid var(--border);
|
|
138
|
+
border-radius: 8px;
|
|
139
|
+
}
|
|
140
|
+
.pagination {
|
|
141
|
+
display: flex;
|
|
142
|
+
gap: 8px;
|
|
143
|
+
margin-top: 12px;
|
|
144
|
+
}
|
|
145
|
+
button.btn {
|
|
146
|
+
background: var(--panel-soft);
|
|
147
|
+
color: var(--text);
|
|
148
|
+
border: 1px solid var(--border);
|
|
149
|
+
border-radius: 6px;
|
|
150
|
+
padding: 6px 12px;
|
|
151
|
+
font: inherit;
|
|
152
|
+
cursor: pointer;
|
|
153
|
+
}
|
|
154
|
+
button.btn:hover { border-color: var(--accent); }
|
|
155
|
+
button.btn:disabled { opacity: .4; cursor: not-allowed; }
|
|
156
|
+
.err-msg {
|
|
157
|
+
color: var(--red);
|
|
158
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
159
|
+
font-size: 12px;
|
|
160
|
+
max-width: 480px;
|
|
161
|
+
white-space: pre-wrap;
|
|
162
|
+
word-break: break-word;
|
|
163
|
+
}
|
|
164
|
+
.err-class { color: var(--amber); font-weight: 600; }
|
|
165
|
+
.args-cell pre {
|
|
166
|
+
margin: 0;
|
|
167
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
168
|
+
font-size: 12px;
|
|
169
|
+
color: var(--text-dim);
|
|
170
|
+
white-space: pre-wrap;
|
|
171
|
+
word-break: break-all;
|
|
172
|
+
max-width: 380px;
|
|
173
|
+
}
|
|
174
|
+
.args-redacted { color: var(--text-dim); font-style: italic; }
|