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.
Files changed (84) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +21 -169
  3. data/README.md +102 -18
  4. data/lib/cosmo/api/counter.rb +14 -11
  5. data/lib/cosmo/api/cron/entry.rb +23 -3
  6. data/lib/cosmo/api/cron.rb +22 -26
  7. data/lib/cosmo/api/job.rb +30 -0
  8. data/lib/cosmo/api/kv.rb +31 -11
  9. data/lib/cosmo/api/registry.rb +68 -0
  10. data/lib/cosmo/api/stats/busy.rb +63 -0
  11. data/lib/cosmo/api/stats/processes.rb +37 -0
  12. data/lib/cosmo/api/stats/totals.rb +35 -0
  13. data/lib/cosmo/api/stats.rb +5 -4
  14. data/lib/cosmo/api/stream.rb +1 -1
  15. data/lib/cosmo/api.rb +3 -2
  16. data/lib/cosmo/batch.rb +9 -2
  17. data/lib/cosmo/cli.rb +27 -7
  18. data/lib/cosmo/client.rb +69 -6
  19. data/lib/cosmo/config.rb +6 -2
  20. data/lib/cosmo/engine.rb +103 -4
  21. data/lib/cosmo/heartbeat.rb +129 -0
  22. data/lib/cosmo/http_server/handler.rb +44 -0
  23. data/lib/cosmo/http_server/health.rb +39 -0
  24. data/lib/cosmo/http_server/ping.rb +23 -0
  25. data/lib/cosmo/http_server.rb +62 -0
  26. data/lib/cosmo/job/failure.rb +52 -0
  27. data/lib/cosmo/job/processor.rb +87 -25
  28. data/lib/cosmo/job/stream_filter.rb +54 -0
  29. data/lib/cosmo/job.rb +2 -0
  30. data/lib/cosmo/logger.rb +21 -3
  31. data/lib/cosmo/processor.rb +46 -16
  32. data/lib/cosmo/sentry/job_processor_middleware.rb +1 -2
  33. data/lib/cosmo/stream/message.rb +2 -1
  34. data/lib/cosmo/stream/processor.rb +5 -0
  35. data/lib/cosmo/utils/json.rb +1 -1
  36. data/lib/cosmo/utils/overrides.rb +199 -8
  37. data/lib/cosmo/utils/thread_pool.rb +5 -0
  38. data/lib/cosmo/version.rb +1 -1
  39. data/lib/cosmo/web/assets/app.css +21 -0
  40. data/lib/cosmo/web/controllers/jobs.rb +28 -4
  41. data/lib/cosmo/web/helpers/application.rb +10 -1
  42. data/lib/cosmo/web/views/batches/index.erb +1 -1
  43. data/lib/cosmo/web/views/jobs/_busy.erb +70 -4
  44. data/lib/cosmo/web/views/jobs/_dead.erb +29 -16
  45. data/lib/cosmo/web/views/jobs/_enqueued.erb +10 -0
  46. data/lib/cosmo/web/views/jobs/_processes.erb +131 -0
  47. data/lib/cosmo/web/views/jobs/_scheduled.erb +1 -1
  48. data/lib/cosmo/web/views/jobs/busy.erb +1 -1
  49. data/lib/cosmo/web/views/jobs/dead.erb +2 -2
  50. data/lib/cosmo/web/views/jobs/enqueued.erb +1 -1
  51. data/lib/cosmo/web/views/jobs/index.erb +7 -1
  52. data/lib/cosmo/web/views/jobs/scheduled.erb +1 -1
  53. data/lib/cosmo/web/views/streams/index.erb +1 -1
  54. data/lib/cosmo/web.rb +2 -0
  55. data/lib/cosmo.rb +13 -0
  56. data/sig/cosmo/api/counter.rbs +2 -4
  57. data/sig/cosmo/api/cron/entry.rbs +2 -0
  58. data/sig/cosmo/api/cron.rbs +4 -2
  59. data/sig/cosmo/api/job.rbs +12 -0
  60. data/sig/cosmo/api/kv.rbs +7 -1
  61. data/sig/cosmo/api/registry.rbs +30 -0
  62. data/sig/cosmo/api/stats/busy.rbs +31 -0
  63. data/sig/cosmo/api/stats/processes.rbs +16 -0
  64. data/sig/cosmo/api/stats/totals.rbs +17 -0
  65. data/sig/cosmo/cli.rbs +4 -0
  66. data/sig/cosmo/client.rbs +17 -2
  67. data/sig/cosmo/engine.rbs +29 -0
  68. data/sig/cosmo/heartbeat.rbs +36 -0
  69. data/sig/cosmo/http_server/handler.rbs +17 -0
  70. data/sig/cosmo/http_server/health.rbs +21 -0
  71. data/sig/cosmo/http_server/ping.rbs +14 -0
  72. data/sig/cosmo/http_server.rbs +22 -0
  73. data/sig/cosmo/job/failure.rbs +28 -0
  74. data/sig/cosmo/job/processor.rbs +17 -1
  75. data/sig/cosmo/job/stream_filter.rbs +25 -0
  76. data/sig/cosmo/logger.rbs +10 -0
  77. data/sig/cosmo/processor.rbs +12 -4
  78. data/sig/cosmo/stream/message.rbs +3 -1
  79. data/sig/cosmo/stream/processor.rbs +2 -0
  80. data/sig/cosmo/utils/thread_pool.rbs +3 -0
  81. data/sig/cosmo.rbs +4 -0
  82. metadata +33 -20
  83. data/lib/cosmo/api/busy.rb +0 -66
  84. 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
- processor_classes = type && PROCESSORS.key?(type.to_sym) ? [PROCESSORS[type.to_sym]] : PROCESSORS.values
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
- signal = handler.wait
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