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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -0
- data/README.md +36 -15
- data/async-background.gemspec +13 -7
- data/lib/async/background/queue/schema.rb +14 -14
- data/lib/async/background/queue/socket_waker.rb +115 -52
- data/lib/async/background/queue/store.rb +21 -38
- data/lib/async/background/runner/queue_execution.rb +32 -8
- data/lib/async/background/runner/schedule.rb +16 -13
- data/lib/async/background/runner.rb +128 -38
- data/lib/async/background/runtime/notification.rb +41 -0
- data/lib/async/background/runtime/semaphore.rb +147 -0
- data/lib/async/background/runtime/task.rb +145 -0
- data/lib/async/background/runtime/task_group.rb +81 -0
- data/lib/async/background/runtime.rb +243 -0
- data/lib/async/background/scheduler.rb +145 -0
- data/lib/async/background/version.rb +1 -1
- data/lib/async/background/web/app.rb +22 -26
- data/lib/async/background/web/configuration.rb +40 -93
- data/lib/async/background/web/cursor.rb +10 -21
- data/lib/async/background/web/event_hub.rb +12 -7
- data/lib/async/background/web/metrics_reader.rb +29 -35
- 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 +32 -78
- data/lib/async/background/web/sql.rb +58 -64
- data/lib/async/background.rb +1 -2
- metadata +26 -22
|
@@ -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 => error
|
|
76
|
+
Runtime.report_error(task, error, @on_error)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,243 @@
|
|
|
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
|
+
def spawn(name: nil, on_error: UNSET, &block)
|
|
52
|
+
Task.spawn(name: name, on_error: on_error, &block)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def scheduler
|
|
56
|
+
Fiber.scheduler
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def scheduler!
|
|
60
|
+
Fiber.scheduler or raise SchedulerRequired, NO_SCHEDULER_MESSAGE
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def native_timeouts?(target = Fiber.scheduler)
|
|
64
|
+
!target.nil? && target.respond_to?(:timeout_after)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def current_task
|
|
68
|
+
fiber_local(CURRENT_TASK_KEY)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def current_task=(task)
|
|
72
|
+
set_fiber_local(CURRENT_TASK_KEY, task)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_waiter(blocker)
|
|
76
|
+
existing = fiber_local(WAITER_KEY)
|
|
77
|
+
if existing&.[](:blocker)
|
|
78
|
+
raise Error, "waiter already parked on #{existing[:blocker].class}; " \
|
|
79
|
+
'a fiber may only park in one place at a time'
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
waiter = {
|
|
83
|
+
fiber: Fiber.current,
|
|
84
|
+
scheduler: scheduler!,
|
|
85
|
+
ready: false,
|
|
86
|
+
queued: false,
|
|
87
|
+
blocker: blocker
|
|
88
|
+
}
|
|
89
|
+
set_fiber_local(WAITER_KEY, waiter)
|
|
90
|
+
waiter
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def park(blocker, waiter, deadline = nil)
|
|
94
|
+
scheduler = waiter[:scheduler]
|
|
95
|
+
task = current_task
|
|
96
|
+
task&.enter_block(waiter)
|
|
97
|
+
|
|
98
|
+
until waiter[:ready] || yield
|
|
99
|
+
if deadline
|
|
100
|
+
remaining = deadline - monotonic_now
|
|
101
|
+
return false if remaining <= 0
|
|
102
|
+
|
|
103
|
+
scheduler.block(blocker, remaining)
|
|
104
|
+
else
|
|
105
|
+
scheduler.block(blocker, nil)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
task&.raise_if_cancelled!
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
true
|
|
112
|
+
ensure
|
|
113
|
+
waiter[:blocker] = nil
|
|
114
|
+
clear_fiber_local(WAITER_KEY, waiter)
|
|
115
|
+
task&.exit_block
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def with_waiter(blocker, waiters)
|
|
119
|
+
waiter = build_waiter(blocker)
|
|
120
|
+
waiter[:queued] = true
|
|
121
|
+
waiters << waiter
|
|
122
|
+
yield waiter
|
|
123
|
+
ensure
|
|
124
|
+
waiters.delete(waiter) if waiter[:queued]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def wake_dequeued(waiter, blocker)
|
|
128
|
+
waiter[:queued] = false
|
|
129
|
+
wake(waiter, blocker)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def wake(waiter, blocker)
|
|
133
|
+
return false if waiter[:ready]
|
|
134
|
+
|
|
135
|
+
waiter[:ready] = true
|
|
136
|
+
fiber = waiter[:fiber]
|
|
137
|
+
return false unless fiber.alive?
|
|
138
|
+
|
|
139
|
+
waiter[:scheduler].unblock(blocker, fiber)
|
|
140
|
+
true
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def monotonic_now
|
|
144
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def deadline_for(timeout)
|
|
148
|
+
return nil if timeout.nil?
|
|
149
|
+
|
|
150
|
+
seconds = Float(timeout)
|
|
151
|
+
raise ArgumentError, 'timeout must be non-negative and finite' unless seconds.finite? && seconds >= 0
|
|
152
|
+
|
|
153
|
+
monotonic_now + seconds
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def error_handler=(handler)
|
|
157
|
+
@error_handler = handler
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def error_handler
|
|
161
|
+
@error_handler
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def with_error_handler(handler)
|
|
165
|
+
previous = @error_handler
|
|
166
|
+
@error_handler = handler
|
|
167
|
+
yield
|
|
168
|
+
ensure
|
|
169
|
+
@error_handler = previous
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def report_error(task, error, handler = UNSET)
|
|
173
|
+
handler = @error_handler if UNSET.equal?(handler)
|
|
174
|
+
return false unless handler
|
|
175
|
+
|
|
176
|
+
handler.call(task, error)
|
|
177
|
+
true
|
|
178
|
+
rescue StandardError
|
|
179
|
+
false
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def fiber_local(key)
|
|
183
|
+
Fiber[key]
|
|
184
|
+
rescue ArgumentError, FiberError
|
|
185
|
+
fiber_local_fallback[key]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def set_fiber_local(key, value)
|
|
189
|
+
Fiber[key] = value
|
|
190
|
+
rescue ArgumentError, FiberError
|
|
191
|
+
fiber_local_fallback[key] = value
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def clear_fiber_local(key, expected)
|
|
195
|
+
current = fiber_local(key)
|
|
196
|
+
set_fiber_local(key, nil) if current.equal?(expected)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def fiber_local_fallback
|
|
200
|
+
fiber = Fiber.current
|
|
201
|
+
store = fiber.instance_variable_get(:@async_background_locals)
|
|
202
|
+
store || fiber.instance_variable_set(:@async_background_locals, {})
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def with_timeout(duration, on_timeout: UNSET)
|
|
206
|
+
return yield if duration.nil?
|
|
207
|
+
|
|
208
|
+
seconds = Float(duration)
|
|
209
|
+
raise ArgumentError, 'timeout must be positive and finite' unless seconds.finite? && seconds.positive?
|
|
210
|
+
|
|
211
|
+
begin
|
|
212
|
+
arm_deadline(seconds) { yield }
|
|
213
|
+
rescue Deadline => e
|
|
214
|
+
raise TimeoutError, e.message if UNSET.equal?(on_timeout)
|
|
215
|
+
|
|
216
|
+
on_timeout
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def arm_deadline(seconds, &block)
|
|
221
|
+
target = scheduler!
|
|
222
|
+
|
|
223
|
+
return target.timeout_after(seconds, Deadline, DEADLINE_MESSAGE, &block) if native_timeouts?(target)
|
|
224
|
+
|
|
225
|
+
warn_missing_timeout_hook(target)
|
|
226
|
+
::Timeout.timeout(seconds, Deadline, DEADLINE_MESSAGE, &block)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def warn_missing_timeout_hook(target)
|
|
230
|
+
key = target.class
|
|
231
|
+
return if @warned_schedulers[key]
|
|
232
|
+
|
|
233
|
+
@warned_schedulers[key] = true
|
|
234
|
+
warn(format(MISSING_TIMEOUT_HOOK_WARNING, key))
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
require_relative 'runtime/notification'
|
|
241
|
+
require_relative 'runtime/semaphore'
|
|
242
|
+
require_relative 'runtime/task'
|
|
243
|
+
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 block.call 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 = block.call }
|
|
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 = block.call
|
|
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 = block.call
|
|
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,14 @@ 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: [:recent_done, :done, :finished_cursor].freeze,
|
|
39
|
+
failed: [:recent_failed, :failed, :finished_cursor].freeze,
|
|
40
|
+
pending: [:pending, :pending, :pending_cursor].freeze
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
35
43
|
def handle(env, head:)
|
|
36
44
|
return Response.unauthorized unless @auth.authorized?(env)
|
|
37
45
|
|
|
@@ -68,16 +76,13 @@ module Async
|
|
|
68
76
|
end
|
|
69
77
|
|
|
70
78
|
def dispatch(route, env)
|
|
79
|
+
return list_response(route, env) if LIST_ROUTES.key?(route)
|
|
80
|
+
|
|
71
81
|
case route
|
|
72
82
|
when :index then Response.html(Assets.render_index(@config))
|
|
73
83
|
when :javascript then Response.javascript(Assets::JS)
|
|
74
84
|
when :stylesheet then Response.stylesheet(Assets::CSS)
|
|
75
85
|
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
86
|
when :metrics then metrics_response
|
|
82
87
|
when :config then config_response
|
|
83
88
|
when :stream then stream_response
|
|
@@ -85,34 +90,25 @@ module Async
|
|
|
85
90
|
end
|
|
86
91
|
end
|
|
87
92
|
|
|
88
|
-
def
|
|
89
|
-
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def in_flight_response(kind, env)
|
|
93
|
+
def list_response(route, env)
|
|
94
|
+
reader, shape, cursor_kind = LIST_ROUTES.fetch(route)
|
|
93
95
|
request = Request.new(env, @config)
|
|
94
|
-
rows = kind == :executing ? @snapshot.executing(limit: request.limit) : @snapshot.claimed(limit: request.limit)
|
|
95
|
-
payload = kind == :executing ? @serializer.executing(rows) : @serializer.claimed(rows)
|
|
96
|
-
Response.json({items: payload})
|
|
97
|
-
end
|
|
98
96
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
Response.json(
|
|
97
|
+
unless cursor_kind
|
|
98
|
+
rows = @snapshot.public_send(reader, limit: request.limit)
|
|
99
|
+
return Response.json({items: @serializer.public_send(shape, rows)})
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
rows = @snapshot.public_send(reader, limit: request.limit, cursor: request.public_send(cursor_kind))
|
|
103
|
+
Response.json(@serializer.public_send(shape, rows))
|
|
106
104
|
end
|
|
107
105
|
|
|
108
|
-
def
|
|
109
|
-
|
|
110
|
-
rows = @snapshot.pending(limit: request.limit, cursor: request.pending_cursor)
|
|
111
|
-
Response.json(@serializer.pending(rows))
|
|
106
|
+
def overview_response
|
|
107
|
+
Response.json(@serializer.overview(@snapshot.overview, metrics_payload))
|
|
112
108
|
end
|
|
113
109
|
|
|
114
110
|
def metrics_response
|
|
115
|
-
Response.json(metrics_payload ||
|
|
111
|
+
Response.json(metrics_payload || MetricsReader::UNAVAILABLE)
|
|
116
112
|
end
|
|
117
113
|
|
|
118
114
|
def metrics_payload
|
|
@@ -53,18 +53,47 @@ module Async
|
|
|
53
53
|
@logger = nil
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
RULES = [
|
|
57
|
+
[:queue_path, ->(value, _) { !value.nil? && !value.to_s.empty? }, 'queue_path must be set'],
|
|
58
|
+
[:auth, ->(value, _) { !value.nil? }, 'auth must be configured (gem ships no permissive default)'],
|
|
59
|
+
[:auth, ->(value, _) { value.respond_to?(:call) },
|
|
60
|
+
'auth must respond to #call(env) and return truthy on success'],
|
|
61
|
+
[:list_limit, ->(value, _) { value.is_a?(Integer) && value.between?(1, MAX_LIST_LIMIT) },
|
|
62
|
+
"list_limit must be an Integer between 1 and #{MAX_LIST_LIMIT}"],
|
|
63
|
+
[:counts_cache_ttl, ->(value, _) { value.is_a?(Numeric) && value >= 0 },
|
|
64
|
+
'counts_cache_ttl must be a non-negative Numeric'],
|
|
65
|
+
[:poll_interval_ms, ->(value, _) { value.is_a?(Integer) && value >= 200 },
|
|
66
|
+
'poll_interval_ms must be an Integer >= 200'],
|
|
67
|
+
[:transport, ->(value, _) { TRANSPORTS.include?(value) },
|
|
68
|
+
"transport must be one of #{TRANSPORTS.inspect}"],
|
|
69
|
+
[:stream_poll_seconds, ->(value, _) { value.is_a?(Numeric) && value >= 0.1 },
|
|
70
|
+
'stream_poll_seconds must be a Numeric >= 0.1'],
|
|
71
|
+
[:stream_heartbeat_seconds, ->(value, _) { value.is_a?(Numeric) && value >= 5 },
|
|
72
|
+
'stream_heartbeat_seconds must be a Numeric >= 5'],
|
|
73
|
+
[:stream_retry_ms, ->(value, _) { value.is_a?(Integer) && value >= 500 },
|
|
74
|
+
'stream_retry_ms must be an Integer >= 500'],
|
|
75
|
+
[:redact_args, ->(value, config) { !config.expose_args || value.nil? || value.respond_to?(:call) },
|
|
76
|
+
'redact_args must respond to #call(args)'],
|
|
77
|
+
[:total_workers, ->(value, config) {
|
|
78
|
+
!config.metrics_enabled? || (value.is_a?(Integer) && value.positive?)
|
|
79
|
+
}, 'metrics_path requires total_workers to be a positive Integer'],
|
|
80
|
+
[:mount_path, ->(value, _) { value.is_a?(String) }, 'mount_path must be a String'],
|
|
81
|
+
[:mount_path, ->(value, _) { value.empty? || value.start_with?('/') },
|
|
82
|
+
'mount_path must start with "/" or be empty'],
|
|
83
|
+
[:mount_path, ->(value, _) { value.empty? || !value.end_with?('/') },
|
|
84
|
+
'mount_path must not end with "/"'],
|
|
85
|
+
[:mount_path, ->(value, _) { value.empty? || !value.match?(/[[:cntrl:]]/) },
|
|
86
|
+
'mount_path must not contain control characters'],
|
|
87
|
+
[:mount_path, ->(value, _) { value.empty? || !value.match?(/\s/) },
|
|
88
|
+
'mount_path must not contain whitespace'],
|
|
89
|
+
[:logger, ->(value, _) { value.nil? || (value.respond_to?(:warn) && value.respond_to?(:error)) },
|
|
90
|
+
'logger must respond to #warn and #error']
|
|
91
|
+
].freeze
|
|
92
|
+
|
|
56
93
|
def validate!
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
validate_cache_ttl!
|
|
61
|
-
validate_poll_interval!
|
|
62
|
-
validate_transport!
|
|
63
|
-
validate_stream!
|
|
64
|
-
validate_redactor!
|
|
65
|
-
validate_metrics!
|
|
66
|
-
validate_mount_path!
|
|
67
|
-
validate_logger!
|
|
94
|
+
RULES.each do |attribute, valid, message|
|
|
95
|
+
raise ConfigurationError, message unless valid.call(public_send(attribute), self)
|
|
96
|
+
end
|
|
68
97
|
self
|
|
69
98
|
end
|
|
70
99
|
|
|
@@ -84,88 +113,6 @@ module Async
|
|
|
84
113
|
def metrics_enabled?
|
|
85
114
|
!metrics_path.nil?
|
|
86
115
|
end
|
|
87
|
-
|
|
88
|
-
private
|
|
89
|
-
|
|
90
|
-
def validate_queue_path!
|
|
91
|
-
raise ConfigurationError, 'queue_path must be set' if queue_path.nil? || queue_path.to_s.empty?
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def validate_auth!
|
|
95
|
-
raise ConfigurationError, 'auth must be configured (gem ships no permissive default)' if auth.nil?
|
|
96
|
-
|
|
97
|
-
return if auth.respond_to?(:call)
|
|
98
|
-
|
|
99
|
-
raise ConfigurationError, 'auth must respond to #call(env) and return truthy on success'
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def validate_list_limit!
|
|
103
|
-
return if list_limit.is_a?(Integer) && list_limit.between?(1, MAX_LIST_LIMIT)
|
|
104
|
-
|
|
105
|
-
raise ConfigurationError, "list_limit must be an Integer between 1 and #{MAX_LIST_LIMIT}"
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def validate_cache_ttl!
|
|
109
|
-
return if counts_cache_ttl.is_a?(Numeric) && counts_cache_ttl >= 0
|
|
110
|
-
|
|
111
|
-
raise ConfigurationError, 'counts_cache_ttl must be a non-negative Numeric'
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def validate_poll_interval!
|
|
115
|
-
return if poll_interval_ms.is_a?(Integer) && poll_interval_ms >= 200
|
|
116
|
-
|
|
117
|
-
raise ConfigurationError, 'poll_interval_ms must be an Integer >= 200'
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def validate_transport!
|
|
121
|
-
return if TRANSPORTS.include?(transport)
|
|
122
|
-
|
|
123
|
-
raise ConfigurationError, "transport must be one of #{TRANSPORTS.inspect}"
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def validate_stream!
|
|
127
|
-
unless stream_poll_seconds.is_a?(Numeric) && stream_poll_seconds >= 0.1
|
|
128
|
-
raise ConfigurationError, 'stream_poll_seconds must be a Numeric >= 0.1'
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
unless stream_heartbeat_seconds.is_a?(Numeric) && stream_heartbeat_seconds >= 5
|
|
132
|
-
raise ConfigurationError, 'stream_heartbeat_seconds must be a Numeric >= 5'
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
return if stream_retry_ms.is_a?(Integer) && stream_retry_ms >= 500
|
|
136
|
-
|
|
137
|
-
raise ConfigurationError, 'stream_retry_ms must be an Integer >= 500'
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def validate_redactor!
|
|
141
|
-
return unless expose_args && redact_args && !redact_args.respond_to?(:call)
|
|
142
|
-
|
|
143
|
-
raise ConfigurationError, 'redact_args must respond to #call(args)'
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def validate_metrics!
|
|
147
|
-
return unless metrics_enabled?
|
|
148
|
-
return if total_workers.is_a?(Integer) && total_workers.positive?
|
|
149
|
-
|
|
150
|
-
raise ConfigurationError, 'metrics_path requires total_workers to be a positive Integer'
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def validate_mount_path!
|
|
154
|
-
raise ConfigurationError, 'mount_path must be a String' unless mount_path.is_a?(String)
|
|
155
|
-
return if mount_path.empty?
|
|
156
|
-
|
|
157
|
-
raise ConfigurationError, 'mount_path must start with "/" or be empty' unless mount_path.start_with?('/')
|
|
158
|
-
raise ConfigurationError, 'mount_path must not end with "/"' if mount_path.end_with?('/')
|
|
159
|
-
raise ConfigurationError, 'mount_path must not contain control characters' if mount_path.match?(/[[:cntrl:]]/)
|
|
160
|
-
raise ConfigurationError, 'mount_path must not contain whitespace' if mount_path.match?(/\s/)
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
def validate_logger!
|
|
164
|
-
return if logger.nil?
|
|
165
|
-
return if logger.respond_to?(:warn) && logger.respond_to?(:error)
|
|
166
|
-
|
|
167
|
-
raise ConfigurationError, 'logger must respond to #warn and #error'
|
|
168
|
-
end
|
|
169
116
|
end
|
|
170
117
|
end
|
|
171
118
|
end
|