cosmonats 0.6.0 → 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 +101 -17
- 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 +33 -5
- 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/processor.rb +39 -4
- 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 +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 +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 +12 -1
- 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/processor.rbs +12 -1
- 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
|
@@ -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
|
data/lib/cosmo/job/processor.rb
CHANGED
|
@@ -5,27 +5,62 @@ require "timeout"
|
|
|
5
5
|
module Cosmo
|
|
6
6
|
module Job
|
|
7
7
|
class Processor < ::Cosmo::Processor
|
|
8
|
+
# Headers the scheduler consumes rather than forwards. The +X-+ ones address the dispatch
|
|
9
|
+
# itself, and NATS sets +Nats-Scheduler+/+Nats-Schedule-Next+ on a message it fires and
|
|
10
|
+
# rejects a publish that carries them back.
|
|
11
|
+
DISPATCH_HEADERS = %w[X-Stream X-Subject X-Execute-At Nats-Expected-Stream
|
|
12
|
+
Nats-Scheduler Nats-Schedule-Next].freeze
|
|
13
|
+
|
|
14
|
+
# @raise [UnknownJobStreamError] when --streams/--stream or COSMO_JOBS_STREAMS names an unconfigured stream
|
|
15
|
+
def self.validate_options!(options)
|
|
16
|
+
StreamFilter.from(options[:streams]).validate!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Array<String>] job streams pulled from, plus +scheduled+ when dispatching scheduled jobs
|
|
20
|
+
def subscriptions
|
|
21
|
+
names = @consumers.map { |(_, config, _)| config[:stream].to_s }
|
|
22
|
+
names << StreamFilter::SCHEDULED.to_s if scheduler? && scheduled_config
|
|
23
|
+
names
|
|
24
|
+
end
|
|
25
|
+
|
|
8
26
|
private
|
|
9
27
|
|
|
10
28
|
def setup
|
|
29
|
+
filter = StreamFilter.from(@options[:streams]).validate!
|
|
30
|
+
|
|
11
31
|
# Initialize singletons before starting to process messages
|
|
12
|
-
API::Busy.instance
|
|
13
|
-
API::
|
|
32
|
+
API::Stats::Busy.instance
|
|
33
|
+
API::Stats::Totals.instance
|
|
14
34
|
Limit.instance
|
|
15
35
|
|
|
16
36
|
jobs_config = Config.dig(:consumers, :jobs)
|
|
17
37
|
jobs_config&.each do |stream_name, config|
|
|
18
|
-
next if stream_name ==
|
|
38
|
+
next if stream_name == StreamFilter::SCHEDULED # scheduled jobs are handled in schedule_loop
|
|
39
|
+
next unless filter.include?(stream_name)
|
|
19
40
|
|
|
20
41
|
@consumers << subscribe(stream_name, config)
|
|
21
42
|
end
|
|
43
|
+
|
|
44
|
+
log_subscriptions
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def log_subscriptions
|
|
48
|
+
return if @consumers.empty?
|
|
49
|
+
|
|
50
|
+
names = @consumers.map { |(_, config, _)| config[:consumer] }
|
|
51
|
+
names << consumer_name(StreamFilter::SCHEDULED) if scheduler? && scheduled_config
|
|
52
|
+
Logger.info "subscribed: #{names.join(", ")}#{" (scheduler off)" unless scheduler?}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def scheduled_config
|
|
56
|
+
Config.dig(:consumers, :jobs, StreamFilter::SCHEDULED)
|
|
22
57
|
end
|
|
23
58
|
|
|
24
59
|
def schedule_loop # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Metrics/AbcSize
|
|
25
|
-
config =
|
|
60
|
+
config = scheduled_config
|
|
26
61
|
return unless config
|
|
27
62
|
|
|
28
|
-
subscription, = subscribe(
|
|
63
|
+
subscription, = subscribe(StreamFilter::SCHEDULED, config)
|
|
29
64
|
while running?
|
|
30
65
|
break unless running?
|
|
31
66
|
|
|
@@ -33,13 +68,18 @@ module Cosmo
|
|
|
33
68
|
timeout = ENV.fetch("COSMO_JOBS_SCHEDULER_FETCH_TIMEOUT", 5).to_f
|
|
34
69
|
messages = fetch(subscription, batch_size: 100, timeout:)
|
|
35
70
|
messages&.each do |message|
|
|
36
|
-
headers = message.header.except(
|
|
71
|
+
headers = message.header.except(*DISPATCH_HEADERS)
|
|
37
72
|
stream, subject, execute_at = message.header.values_at("X-Stream", "X-Subject", "X-Execute-At")
|
|
38
73
|
headers["Nats-Expected-Stream"] = stream
|
|
74
|
+
scheduler = message.header["Nats-Scheduler"]
|
|
75
|
+
if scheduler
|
|
76
|
+
headers["X-Scheduled-By"] = scheduler
|
|
77
|
+
headers["X-Enqueued-At"] = message.metadata.timestamp.to_f.to_s
|
|
78
|
+
end
|
|
39
79
|
execute_at = execute_at.to_i
|
|
40
80
|
|
|
41
81
|
if now >= execute_at
|
|
42
|
-
client.publish(subject, message.data,
|
|
82
|
+
client.publish(subject, message.data, header: headers)
|
|
43
83
|
message.ack
|
|
44
84
|
else
|
|
45
85
|
message.nak(delay: Config.to_ns(execute_at - now))
|
|
@@ -60,16 +100,11 @@ module Cosmo
|
|
|
60
100
|
message = messages.first
|
|
61
101
|
Logger.debug "received messages #{messages.inspect}"
|
|
62
102
|
data = Utils::Json.parse(message.data)
|
|
63
|
-
unless data
|
|
64
|
-
Logger.error ArgumentError.new("malformed payload")
|
|
65
|
-
move_message(message)
|
|
66
|
-
return
|
|
67
|
-
end
|
|
103
|
+
return reject_message(message, ArgumentError.new("malformed payload")) unless data
|
|
68
104
|
|
|
69
105
|
worker_class = Utils::String.safe_constantize(data[:class])
|
|
70
106
|
unless worker_class
|
|
71
|
-
|
|
72
|
-
move_message(message, data)
|
|
107
|
+
reject_message(message, ArgumentError.new("#{data[:class]} class not found"), data)
|
|
73
108
|
notify_batch(data, success: false)
|
|
74
109
|
return
|
|
75
110
|
end
|
|
@@ -115,9 +150,9 @@ module Cosmo
|
|
|
115
150
|
def build_worker(worker_class, data, message)
|
|
116
151
|
worker_class.new.tap do |worker|
|
|
117
152
|
worker.jid = data[:jid]
|
|
118
|
-
worker.enqueued_at = message
|
|
153
|
+
worker.enqueued_at = enqueued_at(message)
|
|
119
154
|
worker.attempt = message.metadata.num_delivered
|
|
120
|
-
worker.scheduled_by = message
|
|
155
|
+
worker.scheduled_by = scheduled_by(message)
|
|
121
156
|
worker.batch_id = data[:batch_id]
|
|
122
157
|
end
|
|
123
158
|
end
|
|
@@ -154,7 +189,7 @@ module Cosmo
|
|
|
154
189
|
end
|
|
155
190
|
|
|
156
191
|
warn_capped(message, data, capped_at) if capped_at
|
|
157
|
-
data[:dead] ? move_message(message, data) : drop_message(message, data)
|
|
192
|
+
data[:dead] ? move_message(message, data, exception) : drop_message(message, data)
|
|
158
193
|
notify_batch(data, success: false)
|
|
159
194
|
true
|
|
160
195
|
end
|
|
@@ -207,7 +242,7 @@ module Cosmo
|
|
|
207
242
|
config = config.dup
|
|
208
243
|
config[:batch_size] = 1
|
|
209
244
|
config[:stream] = stream_name
|
|
210
|
-
config[:consumer] =
|
|
245
|
+
config[:consumer] = consumer_name(stream_name)
|
|
211
246
|
subscription = client.subscribe(config[:subject], config[:consumer], config.except(:subject, :priority, :stream, :batch_size, :consumer))
|
|
212
247
|
[subscription, config, nil]
|
|
213
248
|
end
|
|
@@ -217,16 +252,44 @@ module Cosmo
|
|
|
217
252
|
Logger.debug "job dropped #{data[:jid]}"
|
|
218
253
|
end
|
|
219
254
|
|
|
220
|
-
|
|
255
|
+
# Logs why a message can't be processed at all and parks it in the DLQ.
|
|
256
|
+
def reject_message(message, error, data = nil)
|
|
257
|
+
Logger.error error
|
|
258
|
+
move_message(message, data, error)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def move_message(message, data = nil, exception = nil)
|
|
221
262
|
klass = data ? Utils::String.underscore(data[:class]) : "default"
|
|
222
263
|
headers = { "X-Stream" => message.metadata.stream, "X-Subject" => message.subject }
|
|
264
|
+
headers.merge!(Failure.headers(exception)) if exception
|
|
223
265
|
Client.instance.publish("jobs.dead.#{klass}", message.data, header: headers)
|
|
224
266
|
message.ack
|
|
225
267
|
Logger.debug "job moved #{data&.dig(:jid)} to DLQ"
|
|
226
268
|
end
|
|
227
269
|
|
|
228
270
|
def scheduler?
|
|
229
|
-
true
|
|
271
|
+
@options.fetch(:scheduler, true)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# +Nats-Scheduler+ on a message NATS fired directly into a job stream, and +X-Scheduled-By+
|
|
275
|
+
# on one the scheduler dispatched onward, which may not carry the reserved header.
|
|
276
|
+
def scheduled_by(message)
|
|
277
|
+
header = message.header or return
|
|
278
|
+
|
|
279
|
+
header["X-Scheduled-By"] || header["Nats-Scheduler"]
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# A cron message is enqueued when NATS fires it, not when the scheduler dispatches it on, so
|
|
283
|
+
# the firing forwards its own timestamp in +X-Enqueued-At+ to survive the re-publish.
|
|
284
|
+
def enqueued_at(message)
|
|
285
|
+
forwarded = message.header&.dig("X-Enqueued-At")
|
|
286
|
+
|
|
287
|
+
forwarded ? Time.at(forwarded.to_f) : message.metadata.timestamp
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Durable and per stream, so every process pulls from the same consumer and shares the work.
|
|
291
|
+
def consumer_name(stream_name)
|
|
292
|
+
"consumer-#{stream_name}"
|
|
230
293
|
end
|
|
231
294
|
|
|
232
295
|
def consumers
|
|
@@ -243,8 +306,8 @@ module Cosmo
|
|
|
243
306
|
end
|
|
244
307
|
|
|
245
308
|
def with_stats(message, &block)
|
|
246
|
-
API::Busy.instance.with(message) do
|
|
247
|
-
API::
|
|
309
|
+
API::Stats::Busy.instance.with(message) do
|
|
310
|
+
API::Stats::Totals.instance.with(&block)
|
|
248
311
|
end
|
|
249
312
|
end
|
|
250
313
|
|
|
@@ -253,15 +316,14 @@ module Cosmo
|
|
|
253
316
|
# @param message [NATS::Msg]
|
|
254
317
|
# @param duration [Float, nil]
|
|
255
318
|
#
|
|
256
|
-
# rubocop:disable Lint/UnusedMethodArgument
|
|
319
|
+
# rubocop:disable-next Lint/UnusedMethodArgument
|
|
257
320
|
def perform_job(job_instance, data:, message:, duration: nil)
|
|
258
321
|
if duration
|
|
259
|
-
Timeout.timeout(duration) { job_instance.perform(*data[:args]) }
|
|
322
|
+
Timeout.timeout(duration, Timeout::Error, "execution expired after the #{duration}s duration limit") { job_instance.perform(*data[:args]) }
|
|
260
323
|
else
|
|
261
324
|
job_instance.perform(*data[:args])
|
|
262
325
|
end
|
|
263
326
|
end
|
|
264
|
-
# rubocop:enable Lint/UnusedMethodArgument
|
|
265
327
|
end
|
|
266
328
|
end
|
|
267
329
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
module Job
|
|
5
|
+
# Which job streams a worker subscribes to: every configured stream, or the subset named by
|
|
6
|
+
# +--streams+/+--stream+, falling back to the +COSMO_JOBS_STREAMS+ environment variable.
|
|
7
|
+
#
|
|
8
|
+
# The scheduled stream is a service stream. Naming it selects nothing, it runs unless the worker
|
|
9
|
+
# was started with +--no-scheduler+.
|
|
10
|
+
class StreamFilter
|
|
11
|
+
SCHEDULED = :scheduled
|
|
12
|
+
ENV_NAME = "COSMO_JOBS_STREAMS"
|
|
13
|
+
|
|
14
|
+
# @param names [Array<String>, nil] stream names from the command line
|
|
15
|
+
# @return [StreamFilter]
|
|
16
|
+
def self.from(names)
|
|
17
|
+
new(names || ENV[ENV_NAME]&.split(","))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param names [Array<String>, nil]
|
|
21
|
+
def initialize(names)
|
|
22
|
+
@names = Array(names).map(&:strip).reject(&:empty?)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Boolean] whether every configured stream is selected
|
|
26
|
+
def all?
|
|
27
|
+
@names.empty?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param stream_name [String, Symbol]
|
|
31
|
+
# @return [Boolean] whether the stream is selected
|
|
32
|
+
def include?(stream_name)
|
|
33
|
+
all? || @names.include?(stream_name.to_s)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [StreamFilter] self, so it can be chained onto {.from}
|
|
37
|
+
# @raise [UnknownJobStreamError] when a name is not configured
|
|
38
|
+
def validate!
|
|
39
|
+
return self if all?
|
|
40
|
+
|
|
41
|
+
unknown = @names - configured - [SCHEDULED.to_s]
|
|
42
|
+
raise UnknownJobStreamError.new(unknown, configured) if unknown.any?
|
|
43
|
+
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def configured
|
|
50
|
+
@configured ||= Config.dig(:consumers, :jobs)&.keys.to_a.map(&:to_s) - [SCHEDULED.to_s]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|