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.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +21 -169
  3. data/README.md +296 -116
  4. data/lib/cosmo/active_job/adapter.rb +1 -2
  5. data/lib/cosmo/api/counter.rb +18 -15
  6. data/lib/cosmo/api/cron/entry.rb +23 -3
  7. data/lib/cosmo/api/cron.rb +25 -26
  8. data/lib/cosmo/api/job.rb +30 -0
  9. data/lib/cosmo/api/kv.rb +31 -11
  10. data/lib/cosmo/api/registry.rb +68 -0
  11. data/lib/cosmo/api/stats/busy.rb +63 -0
  12. data/lib/cosmo/api/stats/metrics.rb +109 -0
  13. data/lib/cosmo/api/stats/processes.rb +47 -0
  14. data/lib/cosmo/api/stats/totals.rb +27 -0
  15. data/lib/cosmo/api/stats.rb +8 -6
  16. data/lib/cosmo/api/stream.rb +1 -1
  17. data/lib/cosmo/api.rb +3 -2
  18. data/lib/cosmo/batch/counters.rb +19 -0
  19. data/lib/cosmo/batch.rb +15 -8
  20. data/lib/cosmo/cli.rb +46 -10
  21. data/lib/cosmo/client.rb +61 -7
  22. data/lib/cosmo/config/cosmo.yml +58 -0
  23. data/lib/cosmo/config/settings.rb +20 -0
  24. data/lib/cosmo/config.rb +144 -15
  25. data/lib/cosmo/engine.rb +103 -4
  26. data/lib/cosmo/heartbeat.rb +140 -0
  27. data/lib/cosmo/http_server/handler.rb +44 -0
  28. data/lib/cosmo/http_server/health.rb +39 -0
  29. data/lib/cosmo/http_server/ping.rb +23 -0
  30. data/lib/cosmo/http_server.rb +62 -0
  31. data/lib/cosmo/job/data.rb +17 -12
  32. data/lib/cosmo/job/enqueuer.rb +53 -0
  33. data/lib/cosmo/job/failure.rb +52 -0
  34. data/lib/cosmo/job/limit.rb +2 -2
  35. data/lib/cosmo/job/processor.rb +110 -73
  36. data/lib/cosmo/job/requeue.rb +20 -0
  37. data/lib/cosmo/job/stream_filter.rb +54 -0
  38. data/lib/cosmo/job.rb +10 -22
  39. data/lib/cosmo/logger.rb +15 -2
  40. data/lib/cosmo/middleware/busy.rb +15 -0
  41. data/lib/cosmo/middleware/chain.rb +137 -0
  42. data/lib/cosmo/middleware/limit.rb +50 -0
  43. data/lib/cosmo/middleware/metrics.rb +39 -0
  44. data/lib/cosmo/middleware/sentry.rb +61 -0
  45. data/lib/cosmo/middleware/totals.rb +29 -0
  46. data/lib/cosmo/middleware.rb +7 -0
  47. data/lib/cosmo/processor.rb +49 -8
  48. data/lib/cosmo/publisher.rb +1 -13
  49. data/lib/cosmo/railtie.rb +2 -2
  50. data/lib/cosmo/services.rb +69 -0
  51. data/lib/cosmo/stream/message.rb +2 -1
  52. data/lib/cosmo/stream/processor.rb +12 -1
  53. data/lib/cosmo/utils/hash.rb +7 -8
  54. data/lib/cosmo/utils/json.rb +1 -1
  55. data/lib/cosmo/utils/overrides.rb +16 -10
  56. data/lib/cosmo/utils/thread_pool.rb +5 -0
  57. data/lib/cosmo/version.rb +1 -1
  58. data/lib/cosmo/web/assets/app.css +79 -0
  59. data/lib/cosmo/web/chart.rb +98 -0
  60. data/lib/cosmo/web/controllers/crons.rb +8 -0
  61. data/lib/cosmo/web/controllers/jobs.rb +43 -5
  62. data/lib/cosmo/web/controllers/metrics.rb +48 -0
  63. data/lib/cosmo/web/helpers/application.rb +20 -1
  64. data/lib/cosmo/web/views/batches/_table.erb +11 -0
  65. data/lib/cosmo/web/views/batches/index.erb +1 -1
  66. data/lib/cosmo/web/views/jobs/_busy.erb +70 -4
  67. data/lib/cosmo/web/views/jobs/_dead.erb +29 -16
  68. data/lib/cosmo/web/views/jobs/_enqueued.erb +10 -0
  69. data/lib/cosmo/web/views/jobs/_processes.erb +145 -0
  70. data/lib/cosmo/web/views/jobs/_scheduled.erb +1 -1
  71. data/lib/cosmo/web/views/jobs/_stats.erb +4 -0
  72. data/lib/cosmo/web/views/jobs/_tabs.erb +6 -0
  73. data/lib/cosmo/web/views/jobs/busy.erb +1 -1
  74. data/lib/cosmo/web/views/jobs/dead.erb +2 -2
  75. data/lib/cosmo/web/views/jobs/enqueued.erb +1 -1
  76. data/lib/cosmo/web/views/jobs/index.erb +7 -1
  77. data/lib/cosmo/web/views/jobs/scheduled.erb +1 -1
  78. data/lib/cosmo/web/views/layout.erb +1 -1
  79. data/lib/cosmo/web/views/metrics/_chart.erb +36 -0
  80. data/lib/cosmo/web/views/metrics/_panel.erb +13 -0
  81. data/lib/cosmo/web/views/metrics/_table.erb +41 -0
  82. data/lib/cosmo/web/views/metrics/index.erb +17 -0
  83. data/lib/cosmo/web/views/streams/index.erb +1 -1
  84. data/lib/cosmo/web.rb +5 -0
  85. data/lib/cosmo.rb +51 -0
  86. data/sig/cosmo/api/counter.rbs +3 -6
  87. data/sig/cosmo/api/cron/entry.rbs +2 -0
  88. data/sig/cosmo/api/cron.rbs +4 -2
  89. data/sig/cosmo/api/job.rbs +12 -0
  90. data/sig/cosmo/api/kv.rbs +7 -1
  91. data/sig/cosmo/api/registry.rbs +30 -0
  92. data/sig/cosmo/api/stats/busy.rbs +31 -0
  93. data/sig/cosmo/api/stats/metrics.rbs +42 -0
  94. data/sig/cosmo/api/stats/processes.rbs +19 -0
  95. data/sig/cosmo/api/stats/totals.rbs +18 -0
  96. data/sig/cosmo/batch/counters.rbs +10 -0
  97. data/sig/cosmo/batch.rbs +3 -4
  98. data/sig/cosmo/cli.rbs +6 -0
  99. data/sig/cosmo/client.rbs +15 -2
  100. data/sig/cosmo/config/settings.rbs +31 -0
  101. data/sig/cosmo/config.rbs +59 -5
  102. data/sig/cosmo/engine.rbs +29 -0
  103. data/sig/cosmo/heartbeat.rbs +38 -0
  104. data/sig/cosmo/http_server/handler.rbs +17 -0
  105. data/sig/cosmo/http_server/health.rbs +21 -0
  106. data/sig/cosmo/http_server/ping.rbs +14 -0
  107. data/sig/cosmo/http_server.rbs +22 -0
  108. data/sig/cosmo/job/data.rbs +5 -3
  109. data/sig/cosmo/job/enqueuer.rbs +11 -0
  110. data/sig/cosmo/job/failure.rbs +28 -0
  111. data/sig/cosmo/job/processor.rbs +18 -4
  112. data/sig/cosmo/job/requeue.rbs +9 -0
  113. data/sig/cosmo/job/stream_filter.rbs +25 -0
  114. data/sig/cosmo/job.rbs +0 -2
  115. data/sig/cosmo/logger.rbs +6 -0
  116. data/sig/cosmo/middleware/chain.rbs +51 -0
  117. data/sig/cosmo/middleware/sentry.rbs +14 -0
  118. data/sig/cosmo/middleware.rbs +35 -0
  119. data/sig/cosmo/processor.rbs +14 -1
  120. data/sig/cosmo/publisher.rbs +1 -9
  121. data/sig/cosmo/services.rbs +18 -0
  122. data/sig/cosmo/stream/message.rbs +3 -1
  123. data/sig/cosmo/stream/processor.rbs +4 -0
  124. data/sig/cosmo/utils/hash.rbs +2 -3
  125. data/sig/cosmo/utils/thread_pool.rbs +3 -0
  126. data/sig/cosmo.rbs +15 -0
  127. metadata +62 -22
  128. data/lib/cosmo/api/busy.rb +0 -66
  129. data/lib/cosmo/sentry/auto.rb +0 -6
  130. data/lib/cosmo/sentry/job_processor_middleware.rb +0 -66
  131. 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,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
@@ -36,8 +36,19 @@ module Cosmo
36
36
  @at ? :scheduled : @options[:stream]
37
37
  end
38
38
 
39
- def subject(target: false)
40
- ["jobs", stream(target:).to_s, Utils::String.underscore(@class_name)]
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
@@ -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 = "cosmo_jobs_limits"
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+.