cosmonats 0.6.0 → 0.8.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 +296 -116
- data/lib/cosmo/active_job/adapter.rb +1 -2
- data/lib/cosmo/api/counter.rb +18 -15
- data/lib/cosmo/api/cron/entry.rb +23 -3
- data/lib/cosmo/api/cron.rb +25 -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/metrics.rb +109 -0
- data/lib/cosmo/api/stats/processes.rb +47 -0
- data/lib/cosmo/api/stats/totals.rb +27 -0
- data/lib/cosmo/api/stats.rb +8 -6
- data/lib/cosmo/api/stream.rb +1 -1
- data/lib/cosmo/api.rb +3 -2
- data/lib/cosmo/batch/counters.rb +19 -0
- data/lib/cosmo/batch.rb +15 -8
- data/lib/cosmo/cli.rb +46 -10
- data/lib/cosmo/client.rb +61 -7
- data/lib/cosmo/config/cosmo.yml +58 -0
- data/lib/cosmo/config/settings.rb +20 -0
- data/lib/cosmo/config.rb +144 -15
- data/lib/cosmo/engine.rb +103 -4
- data/lib/cosmo/heartbeat.rb +140 -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/data.rb +17 -12
- data/lib/cosmo/job/enqueuer.rb +53 -0
- data/lib/cosmo/job/failure.rb +52 -0
- data/lib/cosmo/job/limit.rb +2 -2
- data/lib/cosmo/job/processor.rb +110 -73
- data/lib/cosmo/job/requeue.rb +20 -0
- data/lib/cosmo/job/stream_filter.rb +54 -0
- data/lib/cosmo/job.rb +10 -22
- data/lib/cosmo/logger.rb +15 -2
- data/lib/cosmo/middleware/busy.rb +15 -0
- data/lib/cosmo/middleware/chain.rb +137 -0
- data/lib/cosmo/middleware/limit.rb +50 -0
- data/lib/cosmo/middleware/metrics.rb +39 -0
- data/lib/cosmo/middleware/sentry.rb +61 -0
- data/lib/cosmo/middleware/totals.rb +29 -0
- data/lib/cosmo/middleware.rb +7 -0
- data/lib/cosmo/processor.rb +49 -8
- data/lib/cosmo/publisher.rb +1 -13
- data/lib/cosmo/railtie.rb +2 -2
- data/lib/cosmo/services.rb +69 -0
- data/lib/cosmo/stream/message.rb +2 -1
- data/lib/cosmo/stream/processor.rb +12 -1
- data/lib/cosmo/utils/hash.rb +7 -8
- data/lib/cosmo/utils/json.rb +1 -1
- data/lib/cosmo/utils/overrides.rb +16 -10
- data/lib/cosmo/utils/thread_pool.rb +5 -0
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +79 -0
- data/lib/cosmo/web/chart.rb +98 -0
- data/lib/cosmo/web/controllers/crons.rb +8 -0
- data/lib/cosmo/web/controllers/jobs.rb +43 -5
- data/lib/cosmo/web/controllers/metrics.rb +48 -0
- data/lib/cosmo/web/helpers/application.rb +20 -1
- data/lib/cosmo/web/views/batches/_table.erb +11 -0
- 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 +145 -0
- data/lib/cosmo/web/views/jobs/_scheduled.erb +1 -1
- data/lib/cosmo/web/views/jobs/_stats.erb +4 -0
- data/lib/cosmo/web/views/jobs/_tabs.erb +6 -0
- 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/layout.erb +1 -1
- data/lib/cosmo/web/views/metrics/_chart.erb +36 -0
- data/lib/cosmo/web/views/metrics/_panel.erb +13 -0
- data/lib/cosmo/web/views/metrics/_table.erb +41 -0
- data/lib/cosmo/web/views/metrics/index.erb +17 -0
- data/lib/cosmo/web/views/streams/index.erb +1 -1
- data/lib/cosmo/web.rb +5 -0
- data/lib/cosmo.rb +51 -0
- data/sig/cosmo/api/counter.rbs +3 -6
- 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/metrics.rbs +42 -0
- data/sig/cosmo/api/stats/processes.rbs +19 -0
- data/sig/cosmo/api/stats/totals.rbs +18 -0
- data/sig/cosmo/batch/counters.rbs +10 -0
- data/sig/cosmo/batch.rbs +3 -4
- data/sig/cosmo/cli.rbs +6 -0
- data/sig/cosmo/client.rbs +15 -2
- data/sig/cosmo/config/settings.rbs +31 -0
- data/sig/cosmo/config.rbs +59 -5
- data/sig/cosmo/engine.rbs +29 -0
- data/sig/cosmo/heartbeat.rbs +38 -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/data.rbs +5 -3
- data/sig/cosmo/job/enqueuer.rbs +11 -0
- data/sig/cosmo/job/failure.rbs +28 -0
- data/sig/cosmo/job/processor.rbs +18 -4
- data/sig/cosmo/job/requeue.rbs +9 -0
- data/sig/cosmo/job/stream_filter.rbs +25 -0
- data/sig/cosmo/job.rbs +0 -2
- data/sig/cosmo/logger.rbs +6 -0
- data/sig/cosmo/middleware/chain.rbs +51 -0
- data/sig/cosmo/middleware/sentry.rbs +14 -0
- data/sig/cosmo/middleware.rbs +35 -0
- data/sig/cosmo/processor.rbs +14 -1
- data/sig/cosmo/publisher.rbs +1 -9
- data/sig/cosmo/services.rbs +18 -0
- data/sig/cosmo/stream/message.rbs +3 -1
- data/sig/cosmo/stream/processor.rbs +4 -0
- data/sig/cosmo/utils/hash.rbs +2 -3
- data/sig/cosmo/utils/thread_pool.rbs +3 -0
- data/sig/cosmo.rbs +15 -0
- metadata +62 -22
- data/lib/cosmo/api/busy.rb +0 -66
- data/lib/cosmo/sentry/auto.rb +0 -6
- data/lib/cosmo/sentry/job_processor_middleware.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,140 @@
|
|
|
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
|
+
flush
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Stops beating and removes the entry right away, instead of leaving it to expire.
|
|
36
|
+
#
|
|
37
|
+
# @return [void]
|
|
38
|
+
def stop
|
|
39
|
+
@thread&.kill&.join
|
|
40
|
+
@thread = nil
|
|
41
|
+
flush
|
|
42
|
+
processes.unregister(identity)
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
Logger.debug "Heartbeat unregister error: #{e.class} #{e.message}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Writes the current state now, e.g. right after it changes.
|
|
48
|
+
#
|
|
49
|
+
# @return [void]
|
|
50
|
+
def beat
|
|
51
|
+
processes.register(identity, info)
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
Logger.debug "Heartbeat error: #{e.class} #{e.message}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Writes the job metrics recorded since the last flush.
|
|
57
|
+
#
|
|
58
|
+
# @return [void]
|
|
59
|
+
def flush
|
|
60
|
+
API::Stats::Metrics.instance.flush if Config.metrics.enabled
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
Logger.debug "Metrics flush error: #{e.class} #{e.message}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [String]
|
|
66
|
+
def identity
|
|
67
|
+
@identity ||= "#{Socket.gethostname}-#{::Process.pid}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [Hash] everything the Web UI shows about this process
|
|
71
|
+
def info
|
|
72
|
+
static_info.merge(
|
|
73
|
+
state: @engine.state,
|
|
74
|
+
busy: @engine.busy,
|
|
75
|
+
subscriptions: @engine.subscriptions,
|
|
76
|
+
rss: rss,
|
|
77
|
+
nats: nats_info,
|
|
78
|
+
beat_at: Time.now.to_i
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def static_info
|
|
85
|
+
@static_info ||= {
|
|
86
|
+
identity: identity,
|
|
87
|
+
hostname: Socket.gethostname,
|
|
88
|
+
pid: ::Process.pid,
|
|
89
|
+
cmdline: [File.basename($PROGRAM_NAME.to_s), *CLI.instance.argv].shelljoin,
|
|
90
|
+
options: @options,
|
|
91
|
+
concurrency: @engine.concurrency,
|
|
92
|
+
timeout: Config[:timeout],
|
|
93
|
+
http_port: Config.dig(:http, :port),
|
|
94
|
+
version: VERSION,
|
|
95
|
+
ruby: "#{RUBY_ENGINE} #{RUBY_VERSION}",
|
|
96
|
+
started_at: @started_at
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def nats_info
|
|
101
|
+
client = Client.instance
|
|
102
|
+
connection = client.nc
|
|
103
|
+
server = connection.server_info
|
|
104
|
+
uri = connection.connected_server
|
|
105
|
+
{
|
|
106
|
+
name: client.name,
|
|
107
|
+
ip: server[:client_ip],
|
|
108
|
+
client_id: server[:client_id],
|
|
109
|
+
server: server[:server_name],
|
|
110
|
+
server_version: server[:version],
|
|
111
|
+
url: uri && "#{uri.host}:#{uri.port}",
|
|
112
|
+
rtt: rtt(connection),
|
|
113
|
+
reconnects: connection.stats[:reconnects]
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# nats-pure has no RTT call, but a flush is a PING/PONG round trip.
|
|
118
|
+
def rtt(connection)
|
|
119
|
+
started = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
120
|
+
connection.flush(1)
|
|
121
|
+
((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - started) * 1000).round(2)
|
|
122
|
+
rescue StandardError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Resident memory in KB: from procfs on Linux, from ps elsewhere.
|
|
127
|
+
def rss
|
|
128
|
+
status = "/proc/#{::Process.pid}/status"
|
|
129
|
+
return File.read(status)[/VmRSS:\s+(\d+)/, 1].to_i if File.exist?(status)
|
|
130
|
+
|
|
131
|
+
`ps -o rss= -p #{::Process.pid}`.to_i
|
|
132
|
+
rescue StandardError
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def processes
|
|
137
|
+
API::Stats::Processes.instance
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
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
|
data/lib/cosmo/job/data.rb
CHANGED
|
@@ -36,8 +36,19 @@ module Cosmo
|
|
|
36
36
|
@at ? :scheduled : @options[:stream]
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
# @return [String] the subject the job is published to: the custom +subject:+ option, or the one derived
|
|
40
|
+
# from its stream and class
|
|
41
|
+
def subject
|
|
42
|
+
@subject || subject_for(stream)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Hash{String => String, Integer}] dedup id, plus where to dispatch a delayed job once it is due
|
|
46
|
+
def headers
|
|
47
|
+
headers = { "Nats-Msg-Id" => jid }
|
|
48
|
+
return headers unless @at
|
|
49
|
+
|
|
50
|
+
target = stream(target: true)
|
|
51
|
+
headers.merge("X-Execute-At" => @at.to_i, "X-Stream" => target, "X-Subject" => subject_for(target))
|
|
41
52
|
end
|
|
42
53
|
|
|
43
54
|
def as_json
|
|
@@ -49,18 +60,12 @@ module Cosmo
|
|
|
49
60
|
Utils::Json.dump(as_json)
|
|
50
61
|
end
|
|
51
62
|
|
|
52
|
-
def to_args
|
|
53
|
-
headers = { "Nats-Msg-Id" => jid }
|
|
54
|
-
if @at
|
|
55
|
-
headers.merge!("X-Execute-At" => @at.to_i,
|
|
56
|
-
"X-Stream" => stream(target: true),
|
|
57
|
-
"X-Subject" => subject(target: true).join("."))
|
|
58
|
-
end
|
|
59
|
-
[@subject || subject.join("."), to_json, { stream: stream, header: headers }]
|
|
60
|
-
end
|
|
61
|
-
|
|
62
63
|
private
|
|
63
64
|
|
|
65
|
+
def subject_for(stream)
|
|
66
|
+
"jobs.#{stream}.#{Utils::String.underscore(@class_name)}"
|
|
67
|
+
end
|
|
68
|
+
|
|
64
69
|
def validate!
|
|
65
70
|
raise ArgumentError, "stream is not provided" unless @options[:stream]
|
|
66
71
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
module Job
|
|
5
|
+
# The one path a job takes onto its stream, shared by {Job::ClassMethods#perform} and the ActiveJob adapter.
|
|
6
|
+
module Enqueuer
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Publishes the job through {Config#client_middleware}.
|
|
10
|
+
#
|
|
11
|
+
# @param class_name [String]
|
|
12
|
+
# @param args [Array]
|
|
13
|
+
# @param options [Hash] job options, see {Data}
|
|
14
|
+
# @param batch [Batch, nil] the batch the job joins; it is reserved a pending slot before the publish
|
|
15
|
+
# (see Batch#jobs) and released again when the publish fails or a middleware stops it, so the batch never
|
|
16
|
+
# waits on a lost job
|
|
17
|
+
# @return [String, nil] the job's jid, or nil when a client middleware stopped the publish
|
|
18
|
+
# @raise [StreamNotFoundError] when the target stream does not exist
|
|
19
|
+
# @raise [SchedulingDisabledError] for a delayed job while scheduling is turned off
|
|
20
|
+
def enqueue(class_name, args, options, batch: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
|
21
|
+
options = options.merge(batch_id: batch.bid) if batch
|
|
22
|
+
data = Data.new(class_name, args, options)
|
|
23
|
+
raise SchedulingDisabledError if data.stream.to_s == Services::SCHEDULED && !Config.scheduled.enabled
|
|
24
|
+
|
|
25
|
+
batch&.register_job!
|
|
26
|
+
begin
|
|
27
|
+
published = pipe(class_name, data)
|
|
28
|
+
rescue StandardError
|
|
29
|
+
batch&.rollback_job!
|
|
30
|
+
raise
|
|
31
|
+
end
|
|
32
|
+
batch&.rollback_job! unless published
|
|
33
|
+
data.jid if published
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def pipe(class_name, data)
|
|
37
|
+
payload = data.as_json
|
|
38
|
+
published = false
|
|
39
|
+
Config.client_middleware.invoke(class_name, payload, data.stream(target: true)) do
|
|
40
|
+
publish(data, payload)
|
|
41
|
+
published = true
|
|
42
|
+
end
|
|
43
|
+
published
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def publish(data, payload)
|
|
47
|
+
Client.instance.publish(data.subject, Utils::Json.dump(payload), stream: data.stream, header: data.headers)
|
|
48
|
+
rescue NATS::JetStream::Error::NoStreamResponse
|
|
49
|
+
raise StreamNotFoundError, data.stream.to_s
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
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
|
data/lib/cosmo/job/limit.rb
CHANGED
|
@@ -13,14 +13,14 @@ module Cosmo
|
|
|
13
13
|
# expires it automatically via the per-message Nats-TTL header. Both
|
|
14
14
|
# paths leave the slot equally empty -- no tombstone, no delete marker.
|
|
15
15
|
class Limit
|
|
16
|
-
BUCKET = "
|
|
16
|
+
BUCKET = "_cosmolimits"
|
|
17
17
|
|
|
18
18
|
def self.instance
|
|
19
19
|
@instance ||= new
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def initialize
|
|
23
|
-
@kv = API::KV.new(BUCKET, allow_msg_ttl: true)
|
|
23
|
+
@kv = API::KV.new(BUCKET, allow_msg_ttl: true, replicas: Config.replicas)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
# Try to acquire one of the numbered slots for +key+.
|