cosmonats 0.7.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +199 -103
  3. data/lib/cosmo/active_job/adapter.rb +1 -2
  4. data/lib/cosmo/api/counter.rb +16 -16
  5. data/lib/cosmo/api/cron.rb +3 -0
  6. data/lib/cosmo/api/registry.rb +1 -1
  7. data/lib/cosmo/api/stats/busy.rb +1 -1
  8. data/lib/cosmo/api/stats/metrics.rb +109 -0
  9. data/lib/cosmo/api/stats/processes.rb +14 -4
  10. data/lib/cosmo/api/stats/totals.rb +4 -12
  11. data/lib/cosmo/api/stats.rb +3 -2
  12. data/lib/cosmo/batch/counters.rb +19 -0
  13. data/lib/cosmo/batch.rb +6 -6
  14. data/lib/cosmo/cli.rb +21 -5
  15. data/lib/cosmo/client.rb +28 -2
  16. data/lib/cosmo/config/cosmo.yml +58 -0
  17. data/lib/cosmo/config/settings.rb +20 -0
  18. data/lib/cosmo/config.rb +144 -19
  19. data/lib/cosmo/heartbeat.rb +11 -0
  20. data/lib/cosmo/job/data.rb +17 -12
  21. data/lib/cosmo/job/enqueuer.rb +53 -0
  22. data/lib/cosmo/job/limit.rb +2 -2
  23. data/lib/cosmo/job/processor.rb +27 -52
  24. data/lib/cosmo/job/requeue.rb +20 -0
  25. data/lib/cosmo/job.rb +8 -22
  26. data/lib/cosmo/logger.rb +15 -2
  27. data/lib/cosmo/middleware/busy.rb +15 -0
  28. data/lib/cosmo/middleware/chain.rb +137 -0
  29. data/lib/cosmo/middleware/limit.rb +50 -0
  30. data/lib/cosmo/middleware/metrics.rb +39 -0
  31. data/lib/cosmo/{sentry/job_processor_middleware.rb → middleware/sentry.rb} +26 -30
  32. data/lib/cosmo/middleware/totals.rb +29 -0
  33. data/lib/cosmo/middleware.rb +7 -0
  34. data/lib/cosmo/processor.rb +11 -5
  35. data/lib/cosmo/publisher.rb +1 -13
  36. data/lib/cosmo/railtie.rb +2 -2
  37. data/lib/cosmo/services.rb +69 -0
  38. data/lib/cosmo/stream/processor.rb +7 -1
  39. data/lib/cosmo/utils/hash.rb +7 -8
  40. data/lib/cosmo/version.rb +1 -1
  41. data/lib/cosmo/web/assets/app.css +58 -0
  42. data/lib/cosmo/web/chart.rb +98 -0
  43. data/lib/cosmo/web/controllers/crons.rb +8 -0
  44. data/lib/cosmo/web/controllers/jobs.rb +16 -2
  45. data/lib/cosmo/web/controllers/metrics.rb +48 -0
  46. data/lib/cosmo/web/helpers/application.rb +10 -0
  47. data/lib/cosmo/web/views/batches/_table.erb +11 -0
  48. data/lib/cosmo/web/views/jobs/_processes.erb +49 -35
  49. data/lib/cosmo/web/views/jobs/_stats.erb +4 -0
  50. data/lib/cosmo/web/views/jobs/_tabs.erb +6 -0
  51. data/lib/cosmo/web/views/jobs/index.erb +1 -1
  52. data/lib/cosmo/web/views/layout.erb +1 -1
  53. data/lib/cosmo/web/views/metrics/_chart.erb +36 -0
  54. data/lib/cosmo/web/views/metrics/_panel.erb +13 -0
  55. data/lib/cosmo/web/views/metrics/_table.erb +41 -0
  56. data/lib/cosmo/web/views/metrics/index.erb +17 -0
  57. data/lib/cosmo/web.rb +3 -0
  58. data/lib/cosmo.rb +38 -0
  59. data/sig/cosmo/api/counter.rbs +2 -3
  60. data/sig/cosmo/api/stats/busy.rbs +1 -1
  61. data/sig/cosmo/api/stats/metrics.rbs +42 -0
  62. data/sig/cosmo/api/stats/processes.rbs +3 -0
  63. data/sig/cosmo/api/stats/totals.rbs +3 -2
  64. data/sig/cosmo/batch/counters.rbs +10 -0
  65. data/sig/cosmo/batch.rbs +3 -4
  66. data/sig/cosmo/cli.rbs +2 -0
  67. data/sig/cosmo/client.rbs +3 -1
  68. data/sig/cosmo/config/settings.rbs +31 -0
  69. data/sig/cosmo/config.rbs +59 -5
  70. data/sig/cosmo/heartbeat.rbs +2 -0
  71. data/sig/cosmo/job/data.rbs +5 -3
  72. data/sig/cosmo/job/enqueuer.rbs +11 -0
  73. data/sig/cosmo/job/processor.rbs +3 -5
  74. data/sig/cosmo/job/requeue.rbs +9 -0
  75. data/sig/cosmo/job.rbs +0 -2
  76. data/sig/cosmo/logger.rbs +6 -0
  77. data/sig/cosmo/middleware/chain.rbs +51 -0
  78. data/sig/cosmo/middleware/sentry.rbs +14 -0
  79. data/sig/cosmo/middleware.rbs +35 -0
  80. data/sig/cosmo/processor.rbs +2 -0
  81. data/sig/cosmo/publisher.rbs +1 -9
  82. data/sig/cosmo/services.rbs +18 -0
  83. data/sig/cosmo/stream/processor.rbs +2 -0
  84. data/sig/cosmo/utils/hash.rbs +2 -3
  85. data/sig/cosmo.rbs +11 -0
  86. metadata +30 -3
  87. data/lib/cosmo/sentry/auto.rb +0 -6
@@ -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+.
@@ -32,6 +32,7 @@ module Cosmo
32
32
  API::Stats::Busy.instance
33
33
  API::Stats::Totals.instance
34
34
  Limit.instance
35
+ Config.server_middleware
35
36
 
36
37
  jobs_config = Config.dig(:consumers, :jobs)
37
38
  jobs_config&.each do |stream_name, config|
@@ -53,7 +54,7 @@ module Cosmo
53
54
  end
54
55
 
55
56
  def scheduled_config
56
- Config.dig(:consumers, :jobs, StreamFilter::SCHEDULED)
57
+ Services.scheduled_consumer
57
58
  end
58
59
 
59
60
  def schedule_loop # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Metrics/AbcSize
@@ -89,6 +90,7 @@ module Cosmo
89
90
  # to escape #each and kill this thread — schedule_loop only runs once per processor,
90
91
  # so an unhandled exception would silently stop all future scheduled-job dispatch.
91
92
  Logger.error e
93
+ Cosmo.handle_error(e, { source: :scheduler, subject: message.subject })
92
94
  message.nak rescue nil
93
95
  end
94
96
 
@@ -96,7 +98,7 @@ module Cosmo
96
98
  end
97
99
  end
98
100
 
99
- def process(messages, _) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
101
+ def process(messages, _) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
100
102
  message = messages.first
101
103
  Logger.debug "received messages #{messages.inspect}"
102
104
  data = Utils::Json.parse(message.data)
@@ -109,40 +111,36 @@ module Cosmo
109
111
  return
110
112
  end
111
113
 
112
- if worker_class.limits_concurrency?
113
- slot = acquire_concurrency_slot(worker_class, message, data)
114
- return if slot == false
115
- end
116
-
117
- duration = worker_class.default_options[:limit]&.dig(:duration)&.to_i
118
-
119
- with_stats(message) do
114
+ begin
120
115
  sw = stopwatch
121
116
  Logger.with(jid: data[:jid])
122
117
  Logger.info "start"
123
118
 
124
119
  instance = build_worker(worker_class, data, message)
125
- perform_job(instance, data: data, message: message, duration: duration)
120
+ Config.server_middleware.invoke(instance, data, message) do
121
+ perform_job(instance, data: data, message: message)
122
+ end
126
123
 
127
124
  message.ack
128
125
  notify_batch(data, success: true)
129
126
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "done" }
130
- true
127
+ rescue Requeue => e
128
+ message.nak(delay: Config.to_ns(e.delay))
129
+ Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "requeue[#{e.delay}s]" }
131
130
  rescue Timeout::Error => e
132
131
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[timeout]" }
133
- dropped = handle_failure(worker_class, message, data, e)
134
- false if dropped
132
+ Cosmo.handle_error(e, error_context(message, data))
133
+ handle_failure(worker_class, message, data, e)
135
134
  rescue StandardError => e
136
135
  Logger.debug e
137
136
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[error]" }
138
- dropped = handle_failure(worker_class, message, data, e)
139
- false if dropped
137
+ Cosmo.handle_error(e, error_context(message, data))
138
+ handle_failure(worker_class, message, data, e)
140
139
  rescue Exception # rubocop:disable Lint/RescueException
141
140
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[exception]" }
142
141
  raise
143
142
  end
144
143
  ensure
145
- Limit.instance.release(slot) if slot
146
144
  Logger.without(:jid)
147
145
  Logger.debug "processed message #{message.inspect}"
148
146
  end
@@ -157,27 +155,6 @@ module Cosmo
157
155
  end
158
156
  end
159
157
 
160
- # Tries to acquire a concurrency slot for the job.
161
- # Returns the slot key (String) on success, or false if all slots are
162
- # taken (a message is NAK'd with a delay of +retry_in+ before returning
163
- def acquire_concurrency_slot(worker_class, message, data)
164
- options = worker_class.concurrency_options
165
- key = worker_class.concurrency_key(data[:args])
166
-
167
- slot = Limit.instance.acquire(key, jid: data[:jid], limit: options[:limit], duration: options[:duration])
168
- return slot if slot
169
-
170
- message.nak(delay: Config.to_ns(options[:retry_in]))
171
- Logger.debug "concurrency limit reached for #{data[:class]}, re-queueing back #{data[:jid]}"
172
- false
173
- rescue NATS::Error => e
174
- # Unexpected KV failure (e.g. transient NATS error). NAK immediately so
175
- # the message is retried rather than stuck in-flight until ack_wait expires.
176
- Logger.error e
177
- message.nak
178
- false
179
- end
180
-
181
158
  def handle_failure(worker_class, message, data, exception) # rubocop:disable Naming/PredicateMethod
182
159
  current_attempt = message.metadata.num_delivered
183
160
  desired_retries = data[:retry].to_i + 1
@@ -231,6 +208,7 @@ module Cosmo
231
208
  delay.is_a?(Numeric) && delay.positive? ? delay : default_retry_delay(current_attempt)
232
209
  rescue StandardError => e
233
210
  Logger.error e
211
+ Cosmo.handle_error(e, { source: :retry_in, class: data[:class], jid: data[:jid] })
234
212
  default_retry_delay(current_attempt)
235
213
  end
236
214
 
@@ -249,16 +227,24 @@ module Cosmo
249
227
 
250
228
  def drop_message(message, data)
251
229
  message.term
252
- Logger.debug "job dropped #{data[:jid]}"
230
+ Logger.debug "job dropped #{data&.dig(:jid)}"
253
231
  end
254
232
 
255
233
  # Logs why a message can't be processed at all and parks it in the DLQ.
256
234
  def reject_message(message, error, data = nil)
257
235
  Logger.error error
236
+ Cosmo.handle_error(error, error_context(message, data, source: :reject))
258
237
  move_message(message, data, error)
259
238
  end
260
239
 
240
+ def error_context(message, data, source: :job)
241
+ meta = message.metadata
242
+ { source:, stream: meta.stream, subject: message.subject, attempt: meta.num_delivered }.merge(Hash(data))
243
+ end
244
+
261
245
  def move_message(message, data = nil, exception = nil)
246
+ return drop_message(message, data) unless Config.dead.enabled
247
+
262
248
  klass = data ? Utils::String.underscore(data[:class]) : "default"
263
249
  headers = { "X-Stream" => message.metadata.stream, "X-Subject" => message.subject }
264
250
  headers.merge!(Failure.headers(exception)) if exception
@@ -305,24 +291,13 @@ module Cosmo
305
291
  ENV.fetch("COSMO_JOBS_FETCH_TIMEOUT", 0.1).to_f
306
292
  end
307
293
 
308
- def with_stats(message, &block)
309
- API::Stats::Busy.instance.with(message) do
310
- API::Stats::Totals.instance.with(&block)
311
- end
312
- end
313
-
314
294
  # @param job_instance [Cosmo::Job]
315
295
  # @param data [Hash]
316
296
  # @param message [NATS::Msg]
317
- # @param duration [Float, nil]
318
297
  #
319
298
  # rubocop:disable-next Lint/UnusedMethodArgument
320
- def perform_job(job_instance, data:, message:, duration: nil)
321
- if duration
322
- Timeout.timeout(duration, Timeout::Error, "execution expired after the #{duration}s duration limit") { job_instance.perform(*data[:args]) }
323
- else
324
- job_instance.perform(*data[:args])
325
- end
299
+ def perform_job(job_instance, data:, message:)
300
+ job_instance.perform(*data[:args])
326
301
  end
327
302
  end
328
303
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module Job
5
+ # Raised by a middleware to put the job back on its stream for later: the processor naks it with +delay+ instead
6
+ # of failing it. The redelivery still counts as a delivery attempt toward the job's +retry+ and +max_deliver+.
7
+ # Raise it from a middleware that runs before the built-in stats middleware, or the attempt is counted as failed.
8
+ #
9
+ # raise Cosmo::Job::Requeue.new(30) if maintenance?
10
+ class Requeue < StandardError
11
+ attr_reader :delay
12
+
13
+ # @param delay [Numeric] seconds before the job is delivered again
14
+ def initialize(delay = 0)
15
+ @delay = delay
16
+ super("requeued for #{delay}s")
17
+ end
18
+ end
19
+ end
20
+ 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/enqueuer"
5
+ require "cosmo/job/requeue"
4
6
  require "cosmo/job/failure"
5
7
  require "cosmo/job/stream_filter"
6
8
  require "cosmo/job/limit"
@@ -96,30 +98,14 @@ module Cosmo
96
98
  end
97
99
 
98
100
  def perform(*args, async: true, **options)
99
- batch = Batch.current if async
100
- options[:batch_id] = batch.bid if batch
101
- data = Data.new(name, args, default_options.merge(options))
102
- unless async
103
- payload = Utils::Json.parse(data.to_args[1])
104
- raise ArgumentError, "Cannot parse payload" unless payload
105
-
106
- new.perform(*payload[:args])
107
- return
108
- end
101
+ options = default_options.merge(options)
102
+ return Enqueuer.enqueue(name, args, options, batch: Batch.current) if async
109
103
 
110
- publish(data, batch)
111
- end
104
+ payload = Utils::Json.parse(Data.new(name, args, options).to_json)
105
+ raise ArgumentError, "Cannot parse payload" unless payload
112
106
 
113
- # The batch is reserved a pending slot before we know the publish will
114
- # succeed (must happen in that order -- see Batch#jobs). Roll it back
115
- # if it never actually made it onto the stream, so the batch doesn't
116
- # hang waiting for a completion that will never arrive.
117
- def publish(data, batch)
118
- batch&.register_job!
119
- Publisher.publish_job(data)
120
- rescue StandardError
121
- batch&.rollback_job!
122
- raise
107
+ new.perform(*payload[:args])
108
+ nil
123
109
  end
124
110
 
125
111
  def perform_async(*args)
data/lib/cosmo/logger.rb CHANGED
@@ -61,7 +61,12 @@ module Cosmo
61
61
  class << self
62
62
  extend Forwardable
63
63
 
64
- delegate %i[info error debug warn fatal trace] => :instance
64
+ delegate %i[info error debug warn fatal] => :instance
65
+ end
66
+
67
+ # Dropped when the logger has no TRACE level (a plain +::Logger+), rather than flooding its DEBUG.
68
+ def self.trace(...)
69
+ instance.trace(...) if instance.respond_to?(:trace)
65
70
  end
66
71
 
67
72
  def self.with(...)
@@ -82,10 +87,18 @@ module Cosmo
82
87
  def self.coerce_level(level)
83
88
  level.to_s.downcase == "trace" ? TRACE : ::Logger::Severity.coerce(level)
84
89
  end
85
- private_class_method :coerce_level
86
90
 
91
+ # @param logger [::Logger] any stdlib-compatible logger. Its formatter is left alone, so context such as
92
+ # +jid+ and +elapsed+ is only printed when it uses {SimpleFormatter}.
87
93
  def self.instance=(logger)
88
94
  @instance = logger
89
95
  end
96
+
97
+ # +COSMO_LOG_LEVEL+, when set, wins over +level+.
98
+ #
99
+ # @param level [Symbol, String, Integer] +:trace+, +:debug+, +:info+, +:warn+, +:error+, or +:fatal+
100
+ def self.level=(level)
101
+ instance.level = coerce_level(ENV.fetch("COSMO_LOG_LEVEL", level))
102
+ end
90
103
  end
91
104
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module Middleware
5
+ # Lists the job on the Web UI's Busy page while it runs.
6
+ class Busy
7
+ # @param _job [Cosmo::Job]
8
+ # @param _data [Hash]
9
+ # @param message [NATS::Msg]
10
+ def call(_job, _data, message, &)
11
+ API::Stats::Busy.instance.with(message, &)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module Middleware
5
+ # An ordered list of middleware classes wrapped around job execution, outermost first.
6
+ #
7
+ # A middleware is any class whose instances respond to +call(job, data, message)+ and yield to run
8
+ # the rest of the chain. Every invocation builds fresh instances, so a middleware may keep per-job
9
+ # state in instance variables. Adding a class that is already in the chain moves it instead.
10
+ #
11
+ # class Timing
12
+ # def initialize(threshold:) = @threshold = threshold
13
+ #
14
+ # def call(job, data, message)
15
+ # yield
16
+ # end
17
+ # end
18
+ #
19
+ # Cosmo.configure do |config|
20
+ # config.server_middleware { |с| с.add Timing, threshold: 5 }
21
+ # end
22
+ class Chain
23
+ include Enumerable
24
+
25
+ Entry = Struct.new(:klass, :args, :kwargs) do
26
+ def build
27
+ klass.new(*args, **kwargs)
28
+ end
29
+ end
30
+
31
+ # @yieldparam chain [Chain]
32
+ def initialize
33
+ @entries = []
34
+ yield self if block_given?
35
+ end
36
+
37
+ # @yieldparam entry [Entry]
38
+ def each(&)
39
+ @entries.each(&)
40
+ end
41
+
42
+ # Appends +klass+, so it runs innermost, closest to the job.
43
+ #
44
+ # @param klass [Class]
45
+ # @return [Chain]
46
+ def add(klass, *args, **kwargs)
47
+ remove(klass)
48
+ @entries << Entry.new(klass, args, kwargs)
49
+ self
50
+ end
51
+
52
+ # Inserts +klass+ first, so it runs outermost.
53
+ #
54
+ # @param klass [Class]
55
+ # @return [Chain]
56
+ def prepend(klass, *args, **kwargs)
57
+ remove(klass)
58
+ @entries.unshift(Entry.new(klass, args, kwargs))
59
+ self
60
+ end
61
+
62
+ # Inserts +klass+ right before +existing+, or first when +existing+ is not in the chain.
63
+ #
64
+ # @param existing [Class]
65
+ # @param klass [Class]
66
+ # @return [Chain]
67
+ def insert_before(existing, klass, *args, **kwargs)
68
+ entry = take(klass, args, kwargs)
69
+ @entries.insert(index(existing) || 0, entry)
70
+ self
71
+ end
72
+
73
+ # Inserts +klass+ right after +existing+, or last when +existing+ is not in the chain.
74
+ #
75
+ # @param existing [Class]
76
+ # @param klass [Class]
77
+ # @return [Chain]
78
+ def insert_after(existing, klass, *args, **kwargs)
79
+ entry = take(klass, args, kwargs)
80
+ position = index(existing)
81
+ @entries.insert(position ? position + 1 : @entries.size, entry)
82
+ self
83
+ end
84
+
85
+ # @param klass [Class]
86
+ # @return [Chain]
87
+ def remove(klass)
88
+ @entries.delete_if { _1.klass == klass }
89
+ self
90
+ end
91
+
92
+ # @param klass [Class]
93
+ # @return [Boolean]
94
+ def exists?(klass)
95
+ @entries.any? { _1.klass == klass }
96
+ end
97
+ alias include? exists?
98
+
99
+ # @return [Boolean]
100
+ def empty?
101
+ @entries.empty?
102
+ end
103
+
104
+ # @return [Chain]
105
+ def clear
106
+ @entries.clear
107
+ self
108
+ end
109
+
110
+ # Runs the block wrapped in every middleware, passing +args+ to each one's +call+.
111
+ #
112
+ # @return [Object] the outermost middleware's return value, normally the block's
113
+ def invoke(*args, &)
114
+ return yield if empty?
115
+
116
+ traverse(map(&:build), 0, args, &)
117
+ end
118
+
119
+ private
120
+
121
+ def traverse(chain, position, args, &block)
122
+ return yield if position >= chain.size
123
+
124
+ chain[position].call(*args) { traverse(chain, position + 1, args, &block) }
125
+ end
126
+
127
+ def index(klass)
128
+ @entries.index { _1.klass == klass }
129
+ end
130
+
131
+ def take(klass, args, kwargs)
132
+ position = index(klass)
133
+ position ? @entries.delete_at(position) : Entry.new(klass, args, kwargs)
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Cosmo
6
+ module Middleware
7
+ # Enforces a job class's +limit:+ option: a concurrency slot per key, requeueing the job while every slot is
8
+ # taken, and the +duration+ timeout. Jobs without +limit:+ pass straight through. It runs first in the chain,
9
+ # so a requeued job never reaches the stats or user middleware.
10
+ class Limit
11
+ # @param job [Cosmo::Job]
12
+ # @param data [Hash]
13
+ # @param _message [NATS::Msg]
14
+ # @raise [Job::Requeue] while every concurrency slot is taken
15
+ def call(job, data, _message, &)
16
+ limit = job.class.default_options[:limit]
17
+ return yield unless limit
18
+
19
+ slot = acquire(job.class, data)
20
+ timeout(limit[:duration], &)
21
+ ensure
22
+ Job::Limit.instance.release(slot) if slot
23
+ end
24
+
25
+ private
26
+
27
+ def acquire(job_class, data)
28
+ options = job_class.concurrency_options
29
+ return unless options
30
+
31
+ key = job_class.concurrency_key(data[:args])
32
+ slot = Job::Limit.instance.acquire(key, jid: data[:jid], limit: options[:limit], duration: options[:duration])
33
+ raise Job::Requeue, options[:retry_in] unless slot
34
+
35
+ slot
36
+ rescue NATS::Error => e
37
+ Logger.error e
38
+ Cosmo.handle_error(e, { source: :limit, class: job_class.name, jid: data[:jid] })
39
+ raise Job::Requeue
40
+ end
41
+
42
+ def timeout(duration, &)
43
+ return yield unless duration
44
+
45
+ seconds = duration.to_i
46
+ Timeout.timeout(seconds, Timeout::Error, "execution expired after the #{seconds}s duration limit", &)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module Middleware
5
+ # Records every execution for the web UI's Metrics tab while +config.metrics+ is enabled: its run time when it
6
+ # succeeds, a failure otherwise, and the wait from enqueue to the job's first delivery. ActiveJob jobs count under
7
+ # their own class.
8
+ class Metrics
9
+ # @param job [Cosmo::Job]
10
+ # @param data [Hash]
11
+ # @param _message [NATS::Msg]
12
+ def call(job, data, _message) # rubocop:disable Metrics/AbcSize
13
+ return yield unless Config.metrics.enabled
14
+
15
+ name = job_name(job, data)
16
+ wait_ms = (Time.now - job.enqueued_at) * 1000 if job.attempt == 1 && job.enqueued_at
17
+ started = clock
18
+ result = yield
19
+ API::Stats::Metrics.instance.record(name, exec_ms: clock - started, wait_ms:)
20
+ result
21
+ rescue Exception # rubocop:disable Lint/RescueException
22
+ API::Stats::Metrics.instance.record(name, wait_ms:, failed: true) if name
23
+ raise
24
+ end
25
+
26
+ private
27
+
28
+ def job_name(job, data)
29
+ return job.class.name unless defined?(ActiveJobAdapter::Executor) && job.is_a?(ActiveJobAdapter::Executor)
30
+
31
+ data.dig(:args, 0, :job_class).to_s
32
+ end
33
+
34
+ def clock
35
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -2,28 +2,39 @@
2
2
 
3
3
  # rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize
4
4
  module Cosmo
5
- module Sentry
6
- module JobProcessorMiddleware
5
+ module Middleware
6
+ # Wraps every job execution in a Sentry transaction. Errors reach Sentry through {ERROR_HANDLER}, which also
7
+ # captures those outside jobs (stream batches, fetches, the scheduler). Needs +sentry-ruby+, so it is not loaded
8
+ # by default:
9
+ #
10
+ # require "cosmo/middleware/sentry"
11
+ #
12
+ # Cosmo.configure do |config|
13
+ # config.server_middleware { |chain| chain.add Cosmo::Middleware::Sentry }
14
+ # config.error_handlers << Cosmo::Middleware::Sentry::ERROR_HANDLER
15
+ # end
16
+ class Sentry
7
17
  NAME_PREFIX = "Cosmonats"
8
18
  OP_NAME = "queue.cosmonats"
9
19
  SPAN_ORIGIN = "auto.queue.cosmonats"
10
20
  STATUS_OK = 200
11
21
  STATUS_FAIL = 500
12
22
 
13
- # @param job_instance [Cosmo::Job]
23
+ ERROR_HANDLER = lambda do |error, context|
24
+ next unless ::Sentry.initialized?
25
+
26
+ ::Sentry.capture_exception(error, contexts: { cosmonats: context }, hint: { background: true, integration: "cosmonats" })
27
+ end
28
+
29
+ # @param job [Cosmo::Job]
14
30
  # @param data [Hash]
15
31
  # @param message [NATS::Msg]
16
- # @param duration [Float, nil]
17
- def perform_job(job_instance, data:, message:, duration: nil)
18
- unless ::Sentry.initialized?
19
- super
20
- return
21
- end
32
+ def call(job, data, message)
33
+ return yield unless ::Sentry.initialized?
22
34
 
23
35
  scope = ::Sentry.get_current_scope
24
- transaction_name = "#{NAME_PREFIX}/#{job_instance.class.name}"
36
+ transaction_name = "#{NAME_PREFIX}/#{job.class.name}"
25
37
  scope.set_transaction_name(transaction_name, source: :task)
26
-
27
38
  transaction = ::Sentry.start_transaction(
28
39
  name: scope.transaction_name,
29
40
  source: scope.transaction_source,
@@ -35,29 +46,14 @@ module Cosmo
35
46
  transaction&.set_data("messaging.message.retry.count", data[:retry] || 0)
36
47
 
37
48
  begin
38
- super
39
-
49
+ result = yield
40
50
  transaction&.set_http_status(STATUS_OK)
41
51
  transaction&.finish
42
- rescue StandardError => e
43
- ::Sentry.capture_exception(
44
- e,
45
- contexts: {
46
- cosmonats: data.merge(
47
- nats_stream: message.metadata.stream,
48
- nats_subject: message.subject,
49
- timeout_duration: duration
50
- )
51
- },
52
- hint: {
53
- background: true,
54
- integration: "cosmonats"
55
- }
56
- )
52
+ result
53
+ rescue StandardError
57
54
  transaction&.set_http_status(STATUS_FAIL)
58
55
  transaction&.finish
59
-
60
- raise e
56
+ raise
61
57
  end
62
58
  end
63
59
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module Middleware
5
+ # Counts every execution as processed or failed, so a job retried twice and then succeeding adds two failures
6
+ # and one processed. A counter that cannot be written is logged rather than raised, so stats never fail a job.
7
+ class Totals
8
+ # @param _job [Cosmo::Job]
9
+ # @param _data [Hash]
10
+ # @param _message [NATS::Msg]
11
+ def call(_job, _data, _message)
12
+ result = yield
13
+ increment(:processed)
14
+ result
15
+ rescue Exception # rubocop:disable Lint/RescueException
16
+ increment(:failed)
17
+ raise
18
+ end
19
+
20
+ private
21
+
22
+ def increment(key)
23
+ API::Stats::Totals.instance.increment(key)
24
+ rescue StandardError => e
25
+ Logger.debug "Totals #{key} counter error: #{e.class} #{e.message}"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/middleware/chain"
4
+ require "cosmo/middleware/limit"
5
+ require "cosmo/middleware/busy"
6
+ require "cosmo/middleware/totals"
7
+ require "cosmo/middleware/metrics"