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.
- checksums.yaml +4 -4
- data/README.md +199 -103
- data/lib/cosmo/active_job/adapter.rb +1 -2
- data/lib/cosmo/api/counter.rb +16 -16
- data/lib/cosmo/api/cron.rb +3 -0
- data/lib/cosmo/api/registry.rb +1 -1
- data/lib/cosmo/api/stats/busy.rb +1 -1
- data/lib/cosmo/api/stats/metrics.rb +109 -0
- data/lib/cosmo/api/stats/processes.rb +14 -4
- data/lib/cosmo/api/stats/totals.rb +4 -12
- data/lib/cosmo/api/stats.rb +3 -2
- data/lib/cosmo/batch/counters.rb +19 -0
- data/lib/cosmo/batch.rb +6 -6
- data/lib/cosmo/cli.rb +21 -5
- data/lib/cosmo/client.rb +28 -2
- data/lib/cosmo/config/cosmo.yml +58 -0
- data/lib/cosmo/config/settings.rb +20 -0
- data/lib/cosmo/config.rb +144 -19
- data/lib/cosmo/heartbeat.rb +11 -0
- data/lib/cosmo/job/data.rb +17 -12
- data/lib/cosmo/job/enqueuer.rb +53 -0
- data/lib/cosmo/job/limit.rb +2 -2
- data/lib/cosmo/job/processor.rb +27 -52
- data/lib/cosmo/job/requeue.rb +20 -0
- data/lib/cosmo/job.rb +8 -22
- data/lib/cosmo/logger.rb +15 -2
- data/lib/cosmo/middleware/busy.rb +15 -0
- data/lib/cosmo/middleware/chain.rb +137 -0
- data/lib/cosmo/middleware/limit.rb +50 -0
- data/lib/cosmo/middleware/metrics.rb +39 -0
- data/lib/cosmo/{sentry/job_processor_middleware.rb → middleware/sentry.rb} +26 -30
- data/lib/cosmo/middleware/totals.rb +29 -0
- data/lib/cosmo/middleware.rb +7 -0
- data/lib/cosmo/processor.rb +11 -5
- data/lib/cosmo/publisher.rb +1 -13
- data/lib/cosmo/railtie.rb +2 -2
- data/lib/cosmo/services.rb +69 -0
- data/lib/cosmo/stream/processor.rb +7 -1
- data/lib/cosmo/utils/hash.rb +7 -8
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +58 -0
- data/lib/cosmo/web/chart.rb +98 -0
- data/lib/cosmo/web/controllers/crons.rb +8 -0
- data/lib/cosmo/web/controllers/jobs.rb +16 -2
- data/lib/cosmo/web/controllers/metrics.rb +48 -0
- data/lib/cosmo/web/helpers/application.rb +10 -0
- data/lib/cosmo/web/views/batches/_table.erb +11 -0
- data/lib/cosmo/web/views/jobs/_processes.erb +49 -35
- data/lib/cosmo/web/views/jobs/_stats.erb +4 -0
- data/lib/cosmo/web/views/jobs/_tabs.erb +6 -0
- data/lib/cosmo/web/views/jobs/index.erb +1 -1
- data/lib/cosmo/web/views/layout.erb +1 -1
- data/lib/cosmo/web/views/metrics/_chart.erb +36 -0
- data/lib/cosmo/web/views/metrics/_panel.erb +13 -0
- data/lib/cosmo/web/views/metrics/_table.erb +41 -0
- data/lib/cosmo/web/views/metrics/index.erb +17 -0
- data/lib/cosmo/web.rb +3 -0
- data/lib/cosmo.rb +38 -0
- data/sig/cosmo/api/counter.rbs +2 -3
- data/sig/cosmo/api/stats/busy.rbs +1 -1
- data/sig/cosmo/api/stats/metrics.rbs +42 -0
- data/sig/cosmo/api/stats/processes.rbs +3 -0
- data/sig/cosmo/api/stats/totals.rbs +3 -2
- data/sig/cosmo/batch/counters.rbs +10 -0
- data/sig/cosmo/batch.rbs +3 -4
- data/sig/cosmo/cli.rbs +2 -0
- data/sig/cosmo/client.rbs +3 -1
- data/sig/cosmo/config/settings.rbs +31 -0
- data/sig/cosmo/config.rbs +59 -5
- data/sig/cosmo/heartbeat.rbs +2 -0
- data/sig/cosmo/job/data.rbs +5 -3
- data/sig/cosmo/job/enqueuer.rbs +11 -0
- data/sig/cosmo/job/processor.rbs +3 -5
- data/sig/cosmo/job/requeue.rbs +9 -0
- data/sig/cosmo/job.rbs +0 -2
- data/sig/cosmo/logger.rbs +6 -0
- data/sig/cosmo/middleware/chain.rbs +51 -0
- data/sig/cosmo/middleware/sentry.rbs +14 -0
- data/sig/cosmo/middleware.rbs +35 -0
- data/sig/cosmo/processor.rbs +2 -0
- data/sig/cosmo/publisher.rbs +1 -9
- data/sig/cosmo/services.rbs +18 -0
- data/sig/cosmo/stream/processor.rbs +2 -0
- data/sig/cosmo/utils/hash.rbs +2 -3
- data/sig/cosmo.rbs +11 -0
- metadata +30 -3
- data/lib/cosmo/sentry/auto.rb +0 -6
data/lib/cosmo/job/limit.rb
CHANGED
|
@@ -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 = "
|
|
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+.
|
data/lib/cosmo/job/processor.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
|
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
|
|
321
|
-
|
|
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
|
-
|
|
100
|
-
options
|
|
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
|
-
|
|
111
|
-
|
|
104
|
+
payload = Utils::Json.parse(Data.new(name, args, options).to_json)
|
|
105
|
+
raise ArgumentError, "Cannot parse payload" unless payload
|
|
112
106
|
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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}/#{
|
|
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
|
-
|
|
39
|
-
|
|
49
|
+
result = yield
|
|
40
50
|
transaction&.set_http_status(STATUS_OK)
|
|
41
51
|
transaction&.finish
|
|
42
|
-
|
|
43
|
-
|
|
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
|