cosmonats 0.5.1 → 0.7.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/LICENSE.txt +21 -169
- data/README.md +102 -18
- data/lib/cosmo/api/counter.rb +14 -11
- data/lib/cosmo/api/cron/entry.rb +23 -3
- data/lib/cosmo/api/cron.rb +22 -26
- data/lib/cosmo/api/job.rb +30 -0
- data/lib/cosmo/api/kv.rb +31 -11
- data/lib/cosmo/api/registry.rb +68 -0
- data/lib/cosmo/api/stats/busy.rb +63 -0
- data/lib/cosmo/api/stats/processes.rb +37 -0
- data/lib/cosmo/api/stats/totals.rb +35 -0
- data/lib/cosmo/api/stats.rb +5 -4
- data/lib/cosmo/api/stream.rb +1 -1
- data/lib/cosmo/api.rb +3 -2
- data/lib/cosmo/batch.rb +9 -2
- data/lib/cosmo/cli.rb +27 -7
- data/lib/cosmo/client.rb +69 -6
- data/lib/cosmo/config.rb +6 -2
- data/lib/cosmo/engine.rb +103 -4
- data/lib/cosmo/heartbeat.rb +129 -0
- data/lib/cosmo/http_server/handler.rb +44 -0
- data/lib/cosmo/http_server/health.rb +39 -0
- data/lib/cosmo/http_server/ping.rb +23 -0
- data/lib/cosmo/http_server.rb +62 -0
- data/lib/cosmo/job/failure.rb +52 -0
- data/lib/cosmo/job/processor.rb +87 -25
- data/lib/cosmo/job/stream_filter.rb +54 -0
- data/lib/cosmo/job.rb +2 -0
- data/lib/cosmo/logger.rb +21 -3
- data/lib/cosmo/processor.rb +46 -16
- data/lib/cosmo/sentry/job_processor_middleware.rb +1 -2
- data/lib/cosmo/stream/message.rb +2 -1
- data/lib/cosmo/stream/processor.rb +5 -0
- data/lib/cosmo/utils/json.rb +1 -1
- data/lib/cosmo/utils/overrides.rb +199 -8
- data/lib/cosmo/utils/thread_pool.rb +5 -0
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +21 -0
- data/lib/cosmo/web/controllers/jobs.rb +28 -4
- data/lib/cosmo/web/helpers/application.rb +10 -1
- data/lib/cosmo/web/views/batches/index.erb +1 -1
- data/lib/cosmo/web/views/jobs/_busy.erb +70 -4
- data/lib/cosmo/web/views/jobs/_dead.erb +29 -16
- data/lib/cosmo/web/views/jobs/_enqueued.erb +10 -0
- data/lib/cosmo/web/views/jobs/_processes.erb +131 -0
- data/lib/cosmo/web/views/jobs/_scheduled.erb +1 -1
- data/lib/cosmo/web/views/jobs/busy.erb +1 -1
- data/lib/cosmo/web/views/jobs/dead.erb +2 -2
- data/lib/cosmo/web/views/jobs/enqueued.erb +1 -1
- data/lib/cosmo/web/views/jobs/index.erb +7 -1
- data/lib/cosmo/web/views/jobs/scheduled.erb +1 -1
- data/lib/cosmo/web/views/streams/index.erb +1 -1
- data/lib/cosmo/web.rb +2 -0
- data/lib/cosmo.rb +13 -0
- data/sig/cosmo/api/counter.rbs +2 -4
- data/sig/cosmo/api/cron/entry.rbs +2 -0
- data/sig/cosmo/api/cron.rbs +4 -2
- data/sig/cosmo/api/job.rbs +12 -0
- data/sig/cosmo/api/kv.rbs +7 -1
- data/sig/cosmo/api/registry.rbs +30 -0
- data/sig/cosmo/api/stats/busy.rbs +31 -0
- data/sig/cosmo/api/stats/processes.rbs +16 -0
- data/sig/cosmo/api/stats/totals.rbs +17 -0
- data/sig/cosmo/cli.rbs +4 -0
- data/sig/cosmo/client.rbs +17 -2
- data/sig/cosmo/engine.rbs +29 -0
- data/sig/cosmo/heartbeat.rbs +36 -0
- data/sig/cosmo/http_server/handler.rbs +17 -0
- data/sig/cosmo/http_server/health.rbs +21 -0
- data/sig/cosmo/http_server/ping.rbs +14 -0
- data/sig/cosmo/http_server.rbs +22 -0
- data/sig/cosmo/job/failure.rbs +28 -0
- data/sig/cosmo/job/processor.rbs +17 -1
- data/sig/cosmo/job/stream_filter.rbs +25 -0
- data/sig/cosmo/logger.rbs +10 -0
- data/sig/cosmo/processor.rbs +12 -4
- data/sig/cosmo/stream/message.rbs +3 -1
- data/sig/cosmo/stream/processor.rbs +2 -0
- data/sig/cosmo/utils/thread_pool.rbs +3 -0
- data/sig/cosmo.rbs +4 -0
- metadata +33 -20
- data/lib/cosmo/api/busy.rb +0 -66
- data/sig/cosmo/api/busy.rbs +0 -35
data/lib/cosmo/engine.rb
CHANGED
|
@@ -13,38 +13,137 @@ module Cosmo
|
|
|
13
13
|
instance.run(...)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Processor classes a +type+ starts: the matching one, or all of them when it names none.
|
|
17
|
+
#
|
|
18
|
+
# @param type [String, Symbol, nil]
|
|
19
|
+
# @return [Array<Class>]
|
|
20
|
+
def self.processors_for(type)
|
|
21
|
+
type && PROCESSORS.key?(type.to_sym) ? [PROCESSORS[type.to_sym]] : PROCESSORS.values
|
|
22
|
+
end
|
|
23
|
+
|
|
16
24
|
def self.instance
|
|
17
25
|
@instance ||= new
|
|
18
26
|
end
|
|
19
27
|
|
|
28
|
+
attr_reader :concurrency
|
|
29
|
+
|
|
20
30
|
def initialize
|
|
21
31
|
@concurrency = Config.fetch(:concurrency, 1)
|
|
22
32
|
@pool = Utils::ThreadPool.new(@concurrency)
|
|
23
33
|
@running = Concurrent::AtomicBoolean.new
|
|
34
|
+
@quiet = Concurrent::AtomicBoolean.new
|
|
35
|
+
@http_server = nil
|
|
36
|
+
@heartbeat = nil
|
|
24
37
|
end
|
|
25
38
|
|
|
26
39
|
def run(type, options)
|
|
27
|
-
handler = Utils::Signal.trap(:INT, :TERM)
|
|
40
|
+
handler = Utils::Signal.trap(:INT, :TERM, :TSTP, :CONT, :USR1, :TTIN)
|
|
28
41
|
Logger.info "Starting processing, hit Ctrl-C to stop [concurrency=#{@concurrency}]"
|
|
29
42
|
|
|
30
|
-
|
|
31
|
-
@processors = processor_classes.map { _1.run(@pool, @running, options) }
|
|
43
|
+
@processors = self.class.processors_for(type).map { _1.run(@pool, @running, options, quiet: @quiet) }
|
|
32
44
|
if @running.false?
|
|
33
45
|
Logger.warn "Shutting down... (No processors are running)"
|
|
34
46
|
return
|
|
35
47
|
end
|
|
36
48
|
|
|
37
|
-
|
|
49
|
+
@heartbeat = Heartbeat.new(self, options:).start
|
|
50
|
+
start_http_server
|
|
51
|
+
|
|
52
|
+
signal = handle_shutdown(handler)
|
|
38
53
|
Logger.info "Shutting down... (#{signal} received)"
|
|
39
54
|
shutdown
|
|
40
55
|
end
|
|
41
56
|
|
|
57
|
+
def running?
|
|
58
|
+
@running.true?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [String] +running+, +quiet+ (no new work fetched) or +stopping+
|
|
62
|
+
def state
|
|
63
|
+
return "stopping" unless running?
|
|
64
|
+
|
|
65
|
+
@quiet.true? ? "quiet" : "running"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Integer] threads processing messages right now, across all processors
|
|
69
|
+
def busy
|
|
70
|
+
Array(@processors).sum(&:busy)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Hash{Symbol => Array<String>}] what each running processor pulls from, keyed by processor type
|
|
74
|
+
def subscriptions
|
|
75
|
+
Array(@processors).to_h { [PROCESSORS.key(_1.class), _1.subscriptions] }
|
|
76
|
+
end
|
|
77
|
+
|
|
42
78
|
def shutdown
|
|
43
79
|
@running.make_false
|
|
80
|
+
@heartbeat&.beat
|
|
81
|
+
@http_server&.stop
|
|
44
82
|
@pool.shutdown
|
|
45
83
|
Logger.info "Pausing to allow jobs to finish..."
|
|
46
84
|
@pool.wait_for_termination(Config[:timeout])
|
|
85
|
+
@heartbeat&.stop
|
|
47
86
|
Logger.info "Bye!"
|
|
48
87
|
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# HTTPServer is autoloaded, so it's referenced only after the port is checked
|
|
92
|
+
# to keep its optional dependencies (rack, webrick) unloaded otherwise.
|
|
93
|
+
def start_http_server
|
|
94
|
+
port = Config.dig(:http, :port)
|
|
95
|
+
return unless port
|
|
96
|
+
|
|
97
|
+
host = Config.dig(:http, :host) || HTTPServer::DEFAULT_HOST
|
|
98
|
+
@http_server = HTTPServer.new(port: port, host: host).start
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def handle_shutdown(handler)
|
|
102
|
+
loop do
|
|
103
|
+
signal = handler.wait
|
|
104
|
+
case signal.to_s
|
|
105
|
+
when "TSTP" then quiet
|
|
106
|
+
when "CONT" then unquiet
|
|
107
|
+
when "USR1" then drain_and_exit(handler)
|
|
108
|
+
when "TTIN" then dump_threads
|
|
109
|
+
else return signal
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def quiet
|
|
115
|
+
return unless @quiet.make_true
|
|
116
|
+
|
|
117
|
+
Logger.info "Received TSTP, no new jobs will be fetched; finishing in-flight work"
|
|
118
|
+
@heartbeat&.beat
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def unquiet
|
|
122
|
+
return unless @quiet.make_false
|
|
123
|
+
|
|
124
|
+
Logger.info "Received CONT, resuming normal fetching"
|
|
125
|
+
@heartbeat&.beat
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def drain_and_exit(handler)
|
|
129
|
+
return unless @quiet.make_true
|
|
130
|
+
|
|
131
|
+
Logger.info "Received USR1, no new jobs will be fetched, exiting when work drains"
|
|
132
|
+
@heartbeat&.beat
|
|
133
|
+
Thread.new do
|
|
134
|
+
@pool.wait_idle
|
|
135
|
+
handler.push(:TERM)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def dump_threads
|
|
140
|
+
threads = Thread.list
|
|
141
|
+
Logger.warn "Received TTIN, dumping backtraces of #{threads.size} threads"
|
|
142
|
+
threads.each do |thread|
|
|
143
|
+
backtrace = thread.backtrace || ["<no backtrace available>"]
|
|
144
|
+
tid = (thread.object_id ^ ::Process.pid).to_s(36)
|
|
145
|
+
Logger.warn "Thread tid=#{tid} name=#{thread.name} [#{thread.status}]\n#{backtrace.join("\n")}"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
49
148
|
end
|
|
50
149
|
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
module Cosmo
|
|
7
|
+
# Publishes this process's identity and live state to API::Stats::Processes, so the Web UI lists
|
|
8
|
+
# every running worker, the idle ones included.
|
|
9
|
+
class Heartbeat
|
|
10
|
+
INTERVAL = 10
|
|
11
|
+
|
|
12
|
+
# @param engine [Engine]
|
|
13
|
+
# @param options [Hash] the parsed command options
|
|
14
|
+
def initialize(engine, options: {})
|
|
15
|
+
@engine = engine
|
|
16
|
+
@options = options
|
|
17
|
+
@started_at = Time.now.to_i
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Registers the process and keeps refreshing it every INTERVAL seconds.
|
|
21
|
+
#
|
|
22
|
+
# @return [self]
|
|
23
|
+
def start
|
|
24
|
+
beat
|
|
25
|
+
@thread = Thread.new do
|
|
26
|
+
loop do
|
|
27
|
+
sleep(INTERVAL)
|
|
28
|
+
beat
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Stops beating and removes the entry right away, instead of leaving it to expire.
|
|
35
|
+
#
|
|
36
|
+
# @return [void]
|
|
37
|
+
def stop
|
|
38
|
+
@thread&.kill&.join
|
|
39
|
+
@thread = nil
|
|
40
|
+
processes.unregister(identity)
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
Logger.debug "Heartbeat unregister error: #{e.class} #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Writes the current state now, e.g. right after it changes.
|
|
46
|
+
#
|
|
47
|
+
# @return [void]
|
|
48
|
+
def beat
|
|
49
|
+
processes.register(identity, info)
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
Logger.debug "Heartbeat error: #{e.class} #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [String]
|
|
55
|
+
def identity
|
|
56
|
+
@identity ||= "#{Socket.gethostname}-#{::Process.pid}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Hash] everything the Web UI shows about this process
|
|
60
|
+
def info
|
|
61
|
+
static_info.merge(
|
|
62
|
+
state: @engine.state,
|
|
63
|
+
busy: @engine.busy,
|
|
64
|
+
subscriptions: @engine.subscriptions,
|
|
65
|
+
rss: rss,
|
|
66
|
+
nats: nats_info,
|
|
67
|
+
beat_at: Time.now.to_i
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def static_info
|
|
74
|
+
@static_info ||= {
|
|
75
|
+
identity: identity,
|
|
76
|
+
hostname: Socket.gethostname,
|
|
77
|
+
pid: ::Process.pid,
|
|
78
|
+
cmdline: [File.basename($PROGRAM_NAME.to_s), *CLI.instance.argv].shelljoin,
|
|
79
|
+
options: @options,
|
|
80
|
+
concurrency: @engine.concurrency,
|
|
81
|
+
timeout: Config[:timeout],
|
|
82
|
+
http_port: Config.dig(:http, :port),
|
|
83
|
+
version: VERSION,
|
|
84
|
+
ruby: "#{RUBY_ENGINE} #{RUBY_VERSION}",
|
|
85
|
+
started_at: @started_at
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def nats_info
|
|
90
|
+
client = Client.instance
|
|
91
|
+
connection = client.nc
|
|
92
|
+
server = connection.server_info
|
|
93
|
+
uri = connection.connected_server
|
|
94
|
+
{
|
|
95
|
+
name: client.name,
|
|
96
|
+
ip: server[:client_ip],
|
|
97
|
+
client_id: server[:client_id],
|
|
98
|
+
server: server[:server_name],
|
|
99
|
+
server_version: server[:version],
|
|
100
|
+
url: uri && "#{uri.host}:#{uri.port}",
|
|
101
|
+
rtt: rtt(connection),
|
|
102
|
+
reconnects: connection.stats[:reconnects]
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# nats-pure has no RTT call, but a flush is a PING/PONG round trip.
|
|
107
|
+
def rtt(connection)
|
|
108
|
+
started = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
109
|
+
connection.flush(1)
|
|
110
|
+
((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - started) * 1000).round(2)
|
|
111
|
+
rescue StandardError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Resident memory in KB: from procfs on Linux, from ps elsewhere.
|
|
116
|
+
def rss
|
|
117
|
+
status = "/proc/#{::Process.pid}/status"
|
|
118
|
+
return File.read(status)[/VmRSS:\s+(\d+)/, 1].to_i if File.exist?(status)
|
|
119
|
+
|
|
120
|
+
`ps -o rss= -p #{::Process.pid}`.to_i
|
|
121
|
+
rescue StandardError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def processes
|
|
126
|
+
API::Stats::Processes.instance
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module Cosmo
|
|
6
|
+
class HTTPServer
|
|
7
|
+
# Minimal WEBrick servlet adapting requests to a Rack app.
|
|
8
|
+
class Handler < ::WEBrick::HTTPServlet::AbstractServlet
|
|
9
|
+
def initialize(server, app)
|
|
10
|
+
super(server)
|
|
11
|
+
@app = app
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def service(req, res)
|
|
15
|
+
status, headers, body = @app.call(env_for(req))
|
|
16
|
+
res.status = status
|
|
17
|
+
write_headers(res, headers)
|
|
18
|
+
body.each { res.body << _1 }
|
|
19
|
+
ensure
|
|
20
|
+
body.close if body.respond_to?(:close)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def env_for(req)
|
|
26
|
+
env = req.meta_vars.compact
|
|
27
|
+
env.merge!(
|
|
28
|
+
Rack::SCRIPT_NAME => "",
|
|
29
|
+
Rack::PATH_INFO => req.path,
|
|
30
|
+
Rack::QUERY_STRING => req.query_string.to_s,
|
|
31
|
+
Rack::RACK_INPUT => StringIO.new(req.body.to_s).tap(&:binmode),
|
|
32
|
+
Rack::RACK_ERRORS => $stderr,
|
|
33
|
+
Rack::RACK_URL_SCHEME => req.ssl? ? "https" : "http"
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def write_headers(res, headers)
|
|
38
|
+
headers.each do |key, value|
|
|
39
|
+
key.downcase == "set-cookie" ? Array(value).each { res.cookies << _1 } : res[key] = Array(value).join(", ")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
class HTTPServer
|
|
5
|
+
# Rack middleware answering GET /health, every other request goes down the stack.
|
|
6
|
+
# Responds 200 when the engine is running and NATS is connected, 503 otherwise.
|
|
7
|
+
class Health
|
|
8
|
+
PATH = "/health"
|
|
9
|
+
HEADERS = { "content-type" => "application/json", "cache-control" => "no-store" }.freeze
|
|
10
|
+
|
|
11
|
+
def initialize(app, check: nil)
|
|
12
|
+
@app = app
|
|
13
|
+
@check = check || method(:default_check)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(env)
|
|
17
|
+
return @app.call(env) unless env[Rack::PATH_INFO] == PATH
|
|
18
|
+
return [405, HEADERS.merge("allow" => "GET, HEAD"), []] unless %w[GET HEAD].include?(env[Rack::REQUEST_METHOD])
|
|
19
|
+
|
|
20
|
+
checks = @check.call
|
|
21
|
+
healthy = checks.values.all?
|
|
22
|
+
body = Utils::Json.dump({ status: healthy ? "ok" : "unavailable", checks: checks })
|
|
23
|
+
[healthy ? 200 : 503, HEADERS.dup, [body]]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def default_check
|
|
29
|
+
{ engine: Engine.instance.running?, nats: nats_connected? }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def nats_connected?
|
|
33
|
+
Client.instance.nc.connected?
|
|
34
|
+
rescue StandardError
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
class HTTPServer
|
|
5
|
+
# Rack middleware answering GET /ping with 200 and no checks, every other request goes down the stack.
|
|
6
|
+
# Useful as a liveness probe: it only proves the process is up and serving HTTP.
|
|
7
|
+
class Ping
|
|
8
|
+
PATH = "/ping"
|
|
9
|
+
HEADERS = { "content-type" => "text/plain", "cache-control" => "no-store" }.freeze
|
|
10
|
+
|
|
11
|
+
def initialize(app)
|
|
12
|
+
@app = app
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(env)
|
|
16
|
+
return @app.call(env) unless env[Rack::PATH_INFO] == PATH
|
|
17
|
+
return [405, HEADERS.merge("allow" => "GET, HEAD"), []] unless %w[GET HEAD].include?(env[Rack::REQUEST_METHOD])
|
|
18
|
+
|
|
19
|
+
[200, HEADERS.dup, ["pong"]]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "rack"
|
|
5
|
+
require "webrick"
|
|
6
|
+
rescue LoadError => e
|
|
7
|
+
raise LoadError, "Cosmo::HTTPServer requires `rack` and `webrick` gems, add them to your Gemfile (#{e.message})"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
require "json"
|
|
11
|
+
require "cosmo/http_server/handler"
|
|
12
|
+
require "cosmo/http_server/health"
|
|
13
|
+
require "cosmo/http_server/ping"
|
|
14
|
+
|
|
15
|
+
module Cosmo
|
|
16
|
+
# WEBrick server running the Rack app in a background thread.
|
|
17
|
+
class HTTPServer
|
|
18
|
+
DEFAULT_HOST = "0.0.0.0"
|
|
19
|
+
NOT_FOUND = [404, { "content-type" => "application/json" }, ['{"error":"not found"}']].freeze
|
|
20
|
+
|
|
21
|
+
# Routing is done by middlewares, anything unmatched falls through to 404.
|
|
22
|
+
def self.app
|
|
23
|
+
Rack::Builder.app do
|
|
24
|
+
use Health
|
|
25
|
+
use Ping
|
|
26
|
+
run ->(_env) { NOT_FOUND }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(app = self.class.app, port:, host: DEFAULT_HOST)
|
|
31
|
+
@app = app
|
|
32
|
+
@host = host
|
|
33
|
+
@port = port
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def start
|
|
37
|
+
@server = ::WEBrick::HTTPServer.new(
|
|
38
|
+
BindAddress: @host,
|
|
39
|
+
Port: @port,
|
|
40
|
+
Logger: ::WEBrick::Log.new(IO::NULL),
|
|
41
|
+
AccessLog: []
|
|
42
|
+
)
|
|
43
|
+
@server.mount("/", Handler, @app)
|
|
44
|
+
@thread = Thread.new { @server.start }
|
|
45
|
+
Logger.info "HTTP server listening on #{@host}:#{port}"
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def stop
|
|
50
|
+
return unless @server
|
|
51
|
+
|
|
52
|
+
@server.shutdown
|
|
53
|
+
@thread&.join
|
|
54
|
+
@server = @thread = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Actual bound port, useful when started with port 0.
|
|
58
|
+
def port
|
|
59
|
+
@server ? @server.listeners.first.addr[1] : @port
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
module Job
|
|
5
|
+
# Describes why a job died, as NATS headers carried alongside the payload in the
|
|
6
|
+
# dead letter queue. The worker that raised is long gone by the time the dead jobs
|
|
7
|
+
# page is rendered, so the cause has to travel with the message itself.
|
|
8
|
+
class Failure
|
|
9
|
+
HEADER_LIMIT = 1024
|
|
10
|
+
BACKTRACE_LINES = 10
|
|
11
|
+
BACKTRACE_SEPARATOR = " | "
|
|
12
|
+
|
|
13
|
+
# @param exception [Exception]
|
|
14
|
+
# @return [Hash{String => String}] headers describing the failure
|
|
15
|
+
def self.headers(exception)
|
|
16
|
+
new(exception).headers
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param exception [Exception]
|
|
20
|
+
def initialize(exception)
|
|
21
|
+
@exception = exception
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Hash{String => String}]
|
|
25
|
+
def headers
|
|
26
|
+
headers = { "X-Error-Class" => name, "X-Error-Message" => message }
|
|
27
|
+
headers["X-Error-Backtrace"] = backtrace unless backtrace.empty?
|
|
28
|
+
headers
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def name
|
|
34
|
+
@exception.class.name.to_s
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def message
|
|
38
|
+
value = single_line(@exception.message)
|
|
39
|
+
value.empty? ? name : value
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def backtrace
|
|
43
|
+
@backtrace ||= single_line(Array(@exception.backtrace).first(BACKTRACE_LINES).join(BACKTRACE_SEPARATOR))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# NATS header values are a single line, so whitespace collapses and the value is capped.
|
|
47
|
+
def single_line(value)
|
|
48
|
+
value.to_s.gsub(/\s+/, " ").strip[0, HEADER_LIMIT].to_s
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|