async-background 0.7.2 → 1.0.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 +108 -1
- data/README.md +13 -7
- data/async-background.gemspec +3 -1
- data/lib/async/background/metrics.rb +3 -1
- data/lib/async/background/queue/schema.rb +6 -1
- data/lib/async/background/queue/sql.rb +15 -4
- data/lib/async/background/runner/schedule.rb +2 -0
- data/lib/async/background/runner.rb +17 -2
- data/lib/async/background/version.rb +1 -1
- data/lib/async/background/web/app.rb +138 -0
- data/lib/async/background/web/assets.rb +726 -0
- data/lib/async/background/web/auth.rb +19 -0
- data/lib/async/background/web/configuration.rb +158 -0
- data/lib/async/background/web/cursor.rb +58 -0
- data/lib/async/background/web/errors.rb +14 -0
- data/lib/async/background/web/event_hub.rb +194 -0
- data/lib/async/background/web/metrics_reader.rb +96 -0
- data/lib/async/background/web/request.rb +36 -0
- data/lib/async/background/web/response.rb +85 -0
- data/lib/async/background/web/router.rb +30 -0
- data/lib/async/background/web/serializer.rb +154 -0
- data/lib/async/background/web/snapshot.rb +247 -0
- data/lib/async/background/web/sql.rb +88 -0
- data/lib/async/background/web/stream.rb +43 -0
- data/lib/async/background/web.rb +52 -0
- metadata +46 -2
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Async
|
|
4
|
+
module Background
|
|
5
|
+
module Web
|
|
6
|
+
class Auth
|
|
7
|
+
def initialize(callable)
|
|
8
|
+
@callable = callable
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def authorized?(env)
|
|
12
|
+
!!@callable.call(env)
|
|
13
|
+
rescue StandardError
|
|
14
|
+
false
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../queue/store'
|
|
4
|
+
|
|
5
|
+
module Async
|
|
6
|
+
module Background
|
|
7
|
+
module Web
|
|
8
|
+
class Configuration
|
|
9
|
+
DEFAULT_LIST_LIMIT = 50
|
|
10
|
+
MAX_LIST_LIMIT = 200
|
|
11
|
+
DEFAULT_COUNTS_TTL = 3.0
|
|
12
|
+
DEFAULT_POLL_INTERVAL_MS = 2000
|
|
13
|
+
DEFAULT_STREAM_POLL_SECONDS = 0.5
|
|
14
|
+
DEFAULT_STREAM_HEARTBEAT_SECONDS = 25.0
|
|
15
|
+
DEFAULT_STREAM_RETRY_MS = 5000
|
|
16
|
+
TRANSPORTS = %i[polling sse].freeze
|
|
17
|
+
DEFAULT_TRANSPORT = :sse
|
|
18
|
+
DEFAULT_REDACT = ->(args) { args.is_a?(Array) ? args.map { '***' } : args }
|
|
19
|
+
|
|
20
|
+
attr_accessor :queue_path,
|
|
21
|
+
:auth,
|
|
22
|
+
:expose_args,
|
|
23
|
+
:redact_args,
|
|
24
|
+
:metrics_path,
|
|
25
|
+
:total_workers,
|
|
26
|
+
:counts_cache_ttl,
|
|
27
|
+
:list_limit,
|
|
28
|
+
:poll_interval_ms,
|
|
29
|
+
:transport,
|
|
30
|
+
:stream_poll_seconds,
|
|
31
|
+
:stream_heartbeat_seconds,
|
|
32
|
+
:stream_retry_ms,
|
|
33
|
+
:title,
|
|
34
|
+
:mount_path
|
|
35
|
+
|
|
36
|
+
def initialize
|
|
37
|
+
@queue_path = Queue::Store.default_path
|
|
38
|
+
@auth = nil
|
|
39
|
+
@expose_args = false
|
|
40
|
+
@redact_args = DEFAULT_REDACT
|
|
41
|
+
@metrics_path = nil
|
|
42
|
+
@total_workers = nil
|
|
43
|
+
@counts_cache_ttl = DEFAULT_COUNTS_TTL
|
|
44
|
+
@list_limit = DEFAULT_LIST_LIMIT
|
|
45
|
+
@poll_interval_ms = DEFAULT_POLL_INTERVAL_MS
|
|
46
|
+
@transport = DEFAULT_TRANSPORT
|
|
47
|
+
@stream_poll_seconds = DEFAULT_STREAM_POLL_SECONDS
|
|
48
|
+
@stream_heartbeat_seconds = DEFAULT_STREAM_HEARTBEAT_SECONDS
|
|
49
|
+
@stream_retry_ms = DEFAULT_STREAM_RETRY_MS
|
|
50
|
+
@title = 'Async::Background'
|
|
51
|
+
@mount_path = ''
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate!
|
|
55
|
+
validate_queue_path!
|
|
56
|
+
validate_auth!
|
|
57
|
+
validate_list_limit!
|
|
58
|
+
validate_cache_ttl!
|
|
59
|
+
validate_poll_interval!
|
|
60
|
+
validate_transport!
|
|
61
|
+
validate_stream!
|
|
62
|
+
validate_redactor!
|
|
63
|
+
validate_metrics!
|
|
64
|
+
validate_mount_path!
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Strict request-path parsing. Silently changing a malformed requested
|
|
69
|
+
# page size to the default makes API clients repeat or skip work.
|
|
70
|
+
def limit_for(requested)
|
|
71
|
+
return list_limit if requested.nil? || requested.empty?
|
|
72
|
+
|
|
73
|
+
value = Integer(requested, 10)
|
|
74
|
+
raise RequestError, 'limit must be a positive integer' unless value.positive?
|
|
75
|
+
|
|
76
|
+
[value, MAX_LIST_LIMIT].min
|
|
77
|
+
rescue ArgumentError, TypeError
|
|
78
|
+
raise RequestError, 'limit must be a positive integer'
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def metrics_enabled?
|
|
82
|
+
!metrics_path.nil?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def validate_queue_path!
|
|
88
|
+
raise ConfigurationError, 'queue_path must be set' if queue_path.nil? || queue_path.to_s.empty?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate_auth!
|
|
92
|
+
raise ConfigurationError, 'auth must be configured (gem ships no permissive default)' if auth.nil?
|
|
93
|
+
|
|
94
|
+
return if auth.respond_to?(:call)
|
|
95
|
+
|
|
96
|
+
raise ConfigurationError, 'auth must respond to #call(env) and return truthy on success'
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_list_limit!
|
|
100
|
+
return if list_limit.is_a?(Integer) && list_limit.between?(1, MAX_LIST_LIMIT)
|
|
101
|
+
|
|
102
|
+
raise ConfigurationError, "list_limit must be an Integer between 1 and #{MAX_LIST_LIMIT}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def validate_cache_ttl!
|
|
106
|
+
return if counts_cache_ttl.is_a?(Numeric) && counts_cache_ttl >= 0
|
|
107
|
+
|
|
108
|
+
raise ConfigurationError, 'counts_cache_ttl must be a non-negative Numeric'
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def validate_poll_interval!
|
|
112
|
+
return if poll_interval_ms.is_a?(Integer) && poll_interval_ms >= 200
|
|
113
|
+
|
|
114
|
+
raise ConfigurationError, 'poll_interval_ms must be an Integer >= 200'
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def validate_transport!
|
|
118
|
+
return if TRANSPORTS.include?(transport)
|
|
119
|
+
|
|
120
|
+
raise ConfigurationError, "transport must be one of #{TRANSPORTS.inspect}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def validate_stream!
|
|
124
|
+
unless stream_poll_seconds.is_a?(Numeric) && stream_poll_seconds >= 0.1
|
|
125
|
+
raise ConfigurationError, 'stream_poll_seconds must be a Numeric >= 0.1'
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
unless stream_heartbeat_seconds.is_a?(Numeric) && stream_heartbeat_seconds >= 5
|
|
129
|
+
raise ConfigurationError, 'stream_heartbeat_seconds must be a Numeric >= 5'
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
return if stream_retry_ms.is_a?(Integer) && stream_retry_ms >= 500
|
|
133
|
+
|
|
134
|
+
raise ConfigurationError, 'stream_retry_ms must be an Integer >= 500'
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def validate_redactor!
|
|
138
|
+
return unless expose_args && redact_args && !redact_args.respond_to?(:call)
|
|
139
|
+
|
|
140
|
+
raise ConfigurationError, 'redact_args must respond to #call(args)'
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def validate_metrics!
|
|
144
|
+
return unless metrics_enabled?
|
|
145
|
+
return if total_workers.is_a?(Integer) && total_workers.positive?
|
|
146
|
+
|
|
147
|
+
raise ConfigurationError, 'metrics_path requires total_workers to be a positive Integer'
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def validate_mount_path!
|
|
151
|
+
return if mount_path.is_a?(String)
|
|
152
|
+
|
|
153
|
+
raise ConfigurationError, 'mount_path must be a String'
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
|
|
5
|
+
module Async
|
|
6
|
+
module Background
|
|
7
|
+
module Web
|
|
8
|
+
module Cursor
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def encode_finished(finished_at, id)
|
|
12
|
+
encode(finished_at, id)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def encode_pending(run_at, id)
|
|
16
|
+
encode(run_at, id)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def decode_finished(value)
|
|
20
|
+
timestamp, id = decode(value)
|
|
21
|
+
return unless timestamp
|
|
22
|
+
|
|
23
|
+
{finished_at: timestamp, id: id}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def decode_pending(value)
|
|
27
|
+
timestamp, id = decode(value)
|
|
28
|
+
return unless timestamp
|
|
29
|
+
|
|
30
|
+
{run_at: timestamp, id: id}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def encode(timestamp, id)
|
|
34
|
+
return if timestamp.nil? || id.nil?
|
|
35
|
+
|
|
36
|
+
Base64.urlsafe_encode64("#{Float(timestamp)}:#{Integer(id)}", padding: false)
|
|
37
|
+
end
|
|
38
|
+
private_class_method :encode
|
|
39
|
+
|
|
40
|
+
def decode(value)
|
|
41
|
+
return if value.nil? || value.to_s.empty?
|
|
42
|
+
|
|
43
|
+
timestamp_raw, id_raw, extra = Base64.urlsafe_decode64(value.to_s).split(':', 3)
|
|
44
|
+
raise RequestError, 'invalid cursor' if timestamp_raw.nil? || id_raw.nil? || extra
|
|
45
|
+
|
|
46
|
+
timestamp = Float(timestamp_raw)
|
|
47
|
+
id = Integer(id_raw)
|
|
48
|
+
raise RequestError, 'invalid cursor' unless timestamp.finite? && id.positive?
|
|
49
|
+
|
|
50
|
+
[timestamp, id]
|
|
51
|
+
rescue ArgumentError, TypeError
|
|
52
|
+
raise RequestError, 'invalid cursor'
|
|
53
|
+
end
|
|
54
|
+
private_class_method :decode
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Async
|
|
4
|
+
module Background
|
|
5
|
+
module Web
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
class ConfigurationError < Error; end
|
|
8
|
+
class NotConfiguredError < Error; end
|
|
9
|
+
class RequestError < Error; end
|
|
10
|
+
class UnavailableError < Error; end
|
|
11
|
+
class ClosedError < Error; end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Async
|
|
6
|
+
module Background
|
|
7
|
+
module Web
|
|
8
|
+
class EventHub
|
|
9
|
+
HEARTBEAT_FRAME = ":keepalive\n\n"
|
|
10
|
+
UNAVAILABLE_FRAME = "event: unavailable\ndata: #{JSON.generate(error: 'unavailable')}\n\n".freeze
|
|
11
|
+
|
|
12
|
+
class Subscription
|
|
13
|
+
def initialize(clock: nil)
|
|
14
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
@condition = ConditionVariable.new
|
|
17
|
+
@frame = nil
|
|
18
|
+
@closed = false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def publish(frame)
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
return false if @closed
|
|
24
|
+
|
|
25
|
+
@frame = frame
|
|
26
|
+
@condition.signal
|
|
27
|
+
true
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def pop(timeout:)
|
|
32
|
+
deadline = @clock.call + timeout
|
|
33
|
+
|
|
34
|
+
@mutex.synchronize do
|
|
35
|
+
while @frame.nil? && !@closed
|
|
36
|
+
remaining = deadline - @clock.call
|
|
37
|
+
break if remaining <= 0
|
|
38
|
+
|
|
39
|
+
@condition.wait(@mutex, remaining)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
frame = @frame
|
|
43
|
+
@frame = nil
|
|
44
|
+
frame
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def close
|
|
49
|
+
@mutex.synchronize do
|
|
50
|
+
return if @closed
|
|
51
|
+
|
|
52
|
+
@closed = true
|
|
53
|
+
@condition.broadcast
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def closed?
|
|
58
|
+
@mutex.synchronize { @closed }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def initialize(snapshot, serializer, metrics_reader: nil, poll_seconds:, sleeper: nil)
|
|
63
|
+
@snapshot = snapshot
|
|
64
|
+
@serializer = serializer
|
|
65
|
+
@metrics_reader = metrics_reader
|
|
66
|
+
@poll_seconds = poll_seconds
|
|
67
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
68
|
+
@mutex = Mutex.new
|
|
69
|
+
@condition = ConditionVariable.new
|
|
70
|
+
@subscribers = {}
|
|
71
|
+
@closed = false
|
|
72
|
+
@monitor = nil
|
|
73
|
+
@last_data_version = nil
|
|
74
|
+
@unavailable = false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def subscribe
|
|
78
|
+
subscription = Subscription.new
|
|
79
|
+
frame, data_version = current_overview
|
|
80
|
+
|
|
81
|
+
@mutex.synchronize do
|
|
82
|
+
raise ClosedError, 'event hub is closed' if @closed
|
|
83
|
+
|
|
84
|
+
@subscribers[subscription.object_id] = subscription
|
|
85
|
+
@last_data_version ||= data_version
|
|
86
|
+
start_monitor_unless_running!
|
|
87
|
+
@condition.signal
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
[subscription, frame]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def unsubscribe(subscription)
|
|
94
|
+
@mutex.synchronize do
|
|
95
|
+
@subscribers.delete(subscription.object_id)
|
|
96
|
+
end
|
|
97
|
+
subscription.close
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def close
|
|
102
|
+
monitor = nil
|
|
103
|
+
subscribers = nil
|
|
104
|
+
|
|
105
|
+
@mutex.synchronize do
|
|
106
|
+
return if @closed
|
|
107
|
+
|
|
108
|
+
@closed = true
|
|
109
|
+
subscribers = @subscribers.values
|
|
110
|
+
@subscribers.clear
|
|
111
|
+
monitor = @monitor
|
|
112
|
+
@condition.broadcast
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
subscribers.each(&:close)
|
|
116
|
+
monitor&.join(1) unless monitor == Thread.current
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def start_monitor_unless_running!
|
|
123
|
+
return if @monitor&.alive?
|
|
124
|
+
|
|
125
|
+
@monitor = Thread.new { monitor_loop }
|
|
126
|
+
@monitor.name = 'async-background-web-events' if @monitor.respond_to?(:name=)
|
|
127
|
+
@monitor.abort_on_exception = false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def monitor_loop
|
|
131
|
+
loop do
|
|
132
|
+
break unless wait_for_subscribers
|
|
133
|
+
|
|
134
|
+
begin
|
|
135
|
+
detect_change
|
|
136
|
+
rescue ClosedError, UnavailableError
|
|
137
|
+
notify_unavailable
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
@sleeper.call(@poll_seconds)
|
|
141
|
+
end
|
|
142
|
+
ensure
|
|
143
|
+
@mutex.synchronize { @monitor = nil if @monitor == Thread.current }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def wait_for_subscribers
|
|
147
|
+
@mutex.synchronize do
|
|
148
|
+
@condition.wait(@mutex) while !@closed && @subscribers.empty?
|
|
149
|
+
!@closed
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def detect_change
|
|
154
|
+
version = @snapshot.data_version
|
|
155
|
+
previous_version = @mutex.synchronize { @last_data_version }
|
|
156
|
+
if version == previous_version
|
|
157
|
+
@mutex.synchronize { @unavailable = false }
|
|
158
|
+
return
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
frame, observed_version = current_overview
|
|
162
|
+
@mutex.synchronize do
|
|
163
|
+
@last_data_version = observed_version
|
|
164
|
+
@unavailable = false
|
|
165
|
+
end
|
|
166
|
+
broadcast(frame)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def current_overview
|
|
170
|
+
overview = @snapshot.overview(force: true)
|
|
171
|
+
metrics = @metrics_reader&.aggregated
|
|
172
|
+
payload = @serializer.overview(overview, metrics)
|
|
173
|
+
["event: overview\ndata: #{JSON.generate(payload)}\n\n", payload.fetch(:data_version)]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def notify_unavailable
|
|
177
|
+
should_broadcast = @mutex.synchronize do
|
|
178
|
+
next false if @unavailable
|
|
179
|
+
|
|
180
|
+
@unavailable = true
|
|
181
|
+
true
|
|
182
|
+
end
|
|
183
|
+
broadcast(UNAVAILABLE_FRAME) if should_broadcast
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def broadcast(frame)
|
|
187
|
+
subscribers = @mutex.synchronize { @subscribers.values.dup }
|
|
188
|
+
subscribers.each { |subscription| subscription.publish(frame) }
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../clock'
|
|
4
|
+
require_relative '../metrics'
|
|
5
|
+
|
|
6
|
+
module Async
|
|
7
|
+
module Background
|
|
8
|
+
module Web
|
|
9
|
+
class MetricsReader
|
|
10
|
+
include Clock
|
|
11
|
+
|
|
12
|
+
DEFAULT_TTL = 1.0
|
|
13
|
+
EMPTY_WORKERS = [].freeze
|
|
14
|
+
EMPTY_TOTALS = {
|
|
15
|
+
total_runs: 0,
|
|
16
|
+
total_successes: 0,
|
|
17
|
+
total_failures: 0,
|
|
18
|
+
total_timeouts: 0,
|
|
19
|
+
total_skips: 0,
|
|
20
|
+
active_jobs: 0,
|
|
21
|
+
last_run_at: 0,
|
|
22
|
+
last_duration_ms: nil
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
def initialize(path:, total_workers:, ttl: DEFAULT_TTL)
|
|
26
|
+
@path = path
|
|
27
|
+
@total_workers = total_workers
|
|
28
|
+
@ttl = ttl
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
@cache = nil
|
|
31
|
+
@cached_at = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def aggregated
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
now = monotonic_now
|
|
37
|
+
return @cache if cache_current?(now)
|
|
38
|
+
|
|
39
|
+
@cache = read_metrics.freeze
|
|
40
|
+
@cached_at = now
|
|
41
|
+
@cache
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def cache_current?(now)
|
|
48
|
+
@cache && @cached_at && (now - @cached_at) < @ttl
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def read_metrics
|
|
52
|
+
return unavailable unless Metrics.available? && File.file?(@path)
|
|
53
|
+
|
|
54
|
+
workers = Metrics.read_all(total_workers: @total_workers, path: @path)
|
|
55
|
+
{available: true, workers: workers, totals: aggregate(workers)}
|
|
56
|
+
rescue StandardError
|
|
57
|
+
unavailable
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def unavailable
|
|
61
|
+
{available: false, workers: EMPTY_WORKERS, totals: EMPTY_TOTALS}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def aggregate(workers)
|
|
65
|
+
totals = {
|
|
66
|
+
total_runs: 0,
|
|
67
|
+
total_successes: 0,
|
|
68
|
+
total_failures: 0,
|
|
69
|
+
total_timeouts: 0,
|
|
70
|
+
total_skips: 0,
|
|
71
|
+
active_jobs: 0,
|
|
72
|
+
last_run_at: 0,
|
|
73
|
+
last_duration_ms: nil
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
workers.each do |worker|
|
|
77
|
+
totals[:total_runs] += worker[:total_runs].to_i
|
|
78
|
+
totals[:total_successes] += worker[:total_successes].to_i
|
|
79
|
+
totals[:total_failures] += worker[:total_failures].to_i
|
|
80
|
+
totals[:total_timeouts] += worker[:total_timeouts].to_i
|
|
81
|
+
totals[:total_skips] += worker[:total_skips].to_i
|
|
82
|
+
totals[:active_jobs] += worker[:active_jobs].to_i
|
|
83
|
+
|
|
84
|
+
last_run_at = worker[:last_run_at].to_i
|
|
85
|
+
next unless last_run_at > totals[:last_run_at]
|
|
86
|
+
|
|
87
|
+
totals[:last_run_at] = last_run_at
|
|
88
|
+
totals[:last_duration_ms] = worker[:last_duration_ms]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
totals.freeze
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Async
|
|
4
|
+
module Background
|
|
5
|
+
module Web
|
|
6
|
+
class Request
|
|
7
|
+
def initialize(env, config)
|
|
8
|
+
@config = config
|
|
9
|
+
@params = parse(env['QUERY_STRING'])
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def limit
|
|
13
|
+
@config.limit_for(@params['limit'])
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def finished_cursor
|
|
17
|
+
Cursor.decode_finished(@params['cursor'])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def pending_cursor
|
|
21
|
+
Cursor.decode_pending(@params['cursor'])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def parse(query)
|
|
27
|
+
return {} if query.nil? || query.empty?
|
|
28
|
+
|
|
29
|
+
Rack::Utils.parse_query(query)
|
|
30
|
+
rescue StandardError
|
|
31
|
+
raise RequestError, 'invalid query string'
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Async
|
|
6
|
+
module Background
|
|
7
|
+
module Web
|
|
8
|
+
module Response
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
JSON_TYPE = 'application/json; charset=utf-8'
|
|
12
|
+
HTML_TYPE = 'text/html; charset=utf-8'
|
|
13
|
+
TEXT_TYPE = 'text/plain; charset=utf-8'
|
|
14
|
+
JAVASCRIPT_TYPE = 'application/javascript; charset=utf-8'
|
|
15
|
+
CSS_TYPE = 'text/css; charset=utf-8'
|
|
16
|
+
NO_STORE = 'no-store'
|
|
17
|
+
ASSET_CACHE = 'public, max-age=31536000, immutable'
|
|
18
|
+
|
|
19
|
+
UNAUTHORIZED_BODY = 'unauthorized'
|
|
20
|
+
NOT_FOUND_BODY = 'not found'
|
|
21
|
+
BAD_REQUEST_BODY = JSON.generate(error: 'invalid_request').freeze
|
|
22
|
+
UNAVAILABLE_BODY = JSON.generate(error: 'service_unavailable').freeze
|
|
23
|
+
INTERNAL_ERROR_BODY = JSON.generate(error: 'internal_error').freeze
|
|
24
|
+
EVENT_STREAM_TYPE = 'text/event-stream; charset=utf-8'
|
|
25
|
+
|
|
26
|
+
def sse(body)
|
|
27
|
+
[200, sse_headers, body]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def json(payload, status: 200)
|
|
31
|
+
[status, no_store_headers(JSON_TYPE), [JSON.generate(payload)]]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def html(body)
|
|
35
|
+
[200, no_store_headers(HTML_TYPE), [body]]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def javascript(body)
|
|
39
|
+
[200, asset_headers(JAVASCRIPT_TYPE), [body]]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def stylesheet(body)
|
|
43
|
+
[200, asset_headers(CSS_TYPE), [body]]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def unauthorized
|
|
47
|
+
[401, no_store_headers(TEXT_TYPE), [UNAUTHORIZED_BODY]]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def not_found
|
|
51
|
+
[404, no_store_headers(TEXT_TYPE), [NOT_FOUND_BODY]]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def bad_request(message = nil)
|
|
55
|
+
body = message.nil? ? BAD_REQUEST_BODY : JSON.generate(error: 'invalid_request', message: message)
|
|
56
|
+
[400, no_store_headers(JSON_TYPE), [body]]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def unavailable
|
|
60
|
+
[503, no_store_headers(JSON_TYPE), [UNAVAILABLE_BODY]]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def internal_error
|
|
64
|
+
[500, no_store_headers(JSON_TYPE), [INTERNAL_ERROR_BODY]]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def no_store_headers(content_type)
|
|
68
|
+
{'content-type' => content_type, 'cache-control' => NO_STORE}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def asset_headers(content_type)
|
|
72
|
+
{'content-type' => content_type, 'cache-control' => ASSET_CACHE}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def sse_headers
|
|
76
|
+
{
|
|
77
|
+
'content-type' => EVENT_STREAM_TYPE,
|
|
78
|
+
'cache-control' => 'no-cache, no-transform',
|
|
79
|
+
'x-accel-buffering' => 'no'
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Async
|
|
4
|
+
module Background
|
|
5
|
+
module Web
|
|
6
|
+
class Router
|
|
7
|
+
GET_ROUTES = {
|
|
8
|
+
'/' => :index,
|
|
9
|
+
'/assets/app.js' => :javascript,
|
|
10
|
+
'/assets/app.css' => :stylesheet,
|
|
11
|
+
'/api/overview' => :overview,
|
|
12
|
+
'/api/executing' => :executing,
|
|
13
|
+
'/api/claimed' => :claimed,
|
|
14
|
+
'/api/done' => :done,
|
|
15
|
+
'/api/failed' => :failed,
|
|
16
|
+
'/api/pending' => :pending,
|
|
17
|
+
'/api/metrics' => :metrics,
|
|
18
|
+
'/api/config' => :config,
|
|
19
|
+
'/api/stream' => :stream
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
def match(env)
|
|
23
|
+
return unless env['REQUEST_METHOD'] == 'GET'
|
|
24
|
+
|
|
25
|
+
GET_ROUTES[env['PATH_INFO'] || '/']
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|