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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +21 -169
  3. data/README.md +101 -17
  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 +33 -5
  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/processor.rb +39 -4
  31. data/lib/cosmo/sentry/job_processor_middleware.rb +1 -2
  32. data/lib/cosmo/stream/message.rb +2 -1
  33. data/lib/cosmo/stream/processor.rb +5 -0
  34. data/lib/cosmo/utils/json.rb +1 -1
  35. data/lib/cosmo/utils/overrides.rb +16 -10
  36. data/lib/cosmo/utils/thread_pool.rb +5 -0
  37. data/lib/cosmo/version.rb +1 -1
  38. data/lib/cosmo/web/assets/app.css +21 -0
  39. data/lib/cosmo/web/controllers/jobs.rb +28 -4
  40. data/lib/cosmo/web/helpers/application.rb +10 -1
  41. data/lib/cosmo/web/views/batches/index.erb +1 -1
  42. data/lib/cosmo/web/views/jobs/_busy.erb +70 -4
  43. data/lib/cosmo/web/views/jobs/_dead.erb +29 -16
  44. data/lib/cosmo/web/views/jobs/_enqueued.erb +10 -0
  45. data/lib/cosmo/web/views/jobs/_processes.erb +131 -0
  46. data/lib/cosmo/web/views/jobs/_scheduled.erb +1 -1
  47. data/lib/cosmo/web/views/jobs/busy.erb +1 -1
  48. data/lib/cosmo/web/views/jobs/dead.erb +2 -2
  49. data/lib/cosmo/web/views/jobs/enqueued.erb +1 -1
  50. data/lib/cosmo/web/views/jobs/index.erb +7 -1
  51. data/lib/cosmo/web/views/jobs/scheduled.erb +1 -1
  52. data/lib/cosmo/web/views/streams/index.erb +1 -1
  53. data/lib/cosmo/web.rb +2 -0
  54. data/lib/cosmo.rb +13 -0
  55. data/sig/cosmo/api/counter.rbs +2 -4
  56. data/sig/cosmo/api/cron/entry.rbs +2 -0
  57. data/sig/cosmo/api/cron.rbs +4 -2
  58. data/sig/cosmo/api/job.rbs +12 -0
  59. data/sig/cosmo/api/kv.rbs +7 -1
  60. data/sig/cosmo/api/registry.rbs +30 -0
  61. data/sig/cosmo/api/stats/busy.rbs +31 -0
  62. data/sig/cosmo/api/stats/processes.rbs +16 -0
  63. data/sig/cosmo/api/stats/totals.rbs +17 -0
  64. data/sig/cosmo/cli.rbs +4 -0
  65. data/sig/cosmo/client.rbs +12 -1
  66. data/sig/cosmo/engine.rbs +29 -0
  67. data/sig/cosmo/heartbeat.rbs +36 -0
  68. data/sig/cosmo/http_server/handler.rbs +17 -0
  69. data/sig/cosmo/http_server/health.rbs +21 -0
  70. data/sig/cosmo/http_server/ping.rbs +14 -0
  71. data/sig/cosmo/http_server.rbs +22 -0
  72. data/sig/cosmo/job/failure.rbs +28 -0
  73. data/sig/cosmo/job/processor.rbs +17 -1
  74. data/sig/cosmo/job/stream_filter.rbs +25 -0
  75. data/sig/cosmo/processor.rbs +12 -1
  76. data/sig/cosmo/stream/message.rbs +3 -1
  77. data/sig/cosmo/stream/processor.rbs +2 -0
  78. data/sig/cosmo/utils/thread_pool.rbs +3 -0
  79. data/sig/cosmo.rbs +4 -0
  80. metadata +33 -20
  81. data/lib/cosmo/api/busy.rb +0 -66
  82. 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
@@ -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::Counter.instance
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 == :scheduled # scheduled jobs are handled in schedule_loop
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 = Config.dig(:consumers, :jobs, :scheduled)
60
+ config = scheduled_config
26
61
  return unless config
27
62
 
28
- subscription, = subscribe(:scheduled, config)
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("X-Stream", "X-Subject", "X-Execute-At", "Nats-Expected-Stream")
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, headers: headers)
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
- Logger.error ArgumentError.new("#{data[:class]} class not found")
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.metadata.timestamp
153
+ worker.enqueued_at = enqueued_at(message)
119
154
  worker.attempt = message.metadata.num_delivered
120
- worker.scheduled_by = message.header&.dig("Nats-Scheduler")
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] = "consumer-#{stream_name}"
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
- def move_message(message, data = nil)
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::Counter.instance.with(&block)
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
data/lib/cosmo/job.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "cosmo/job/data"
4
+ require "cosmo/job/failure"
5
+ require "cosmo/job/stream_filter"
4
6
  require "cosmo/job/limit"
5
7
  require "cosmo/job/processor"
6
8