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.
- checksums.yaml +4 -4
- data/LICENSE.txt +21 -169
- data/README.md +296 -116
- data/lib/cosmo/active_job/adapter.rb +1 -2
- data/lib/cosmo/api/counter.rb +18 -15
- data/lib/cosmo/api/cron/entry.rb +23 -3
- data/lib/cosmo/api/cron.rb +25 -26
- data/lib/cosmo/api/job.rb +30 -0
- data/lib/cosmo/api/kv.rb +31 -11
- data/lib/cosmo/api/registry.rb +68 -0
- data/lib/cosmo/api/stats/busy.rb +63 -0
- data/lib/cosmo/api/stats/metrics.rb +109 -0
- data/lib/cosmo/api/stats/processes.rb +47 -0
- data/lib/cosmo/api/stats/totals.rb +27 -0
- data/lib/cosmo/api/stats.rb +8 -6
- data/lib/cosmo/api/stream.rb +1 -1
- data/lib/cosmo/api.rb +3 -2
- data/lib/cosmo/batch/counters.rb +19 -0
- data/lib/cosmo/batch.rb +15 -8
- data/lib/cosmo/cli.rb +46 -10
- data/lib/cosmo/client.rb +61 -7
- data/lib/cosmo/config/cosmo.yml +58 -0
- data/lib/cosmo/config/settings.rb +20 -0
- data/lib/cosmo/config.rb +144 -15
- data/lib/cosmo/engine.rb +103 -4
- data/lib/cosmo/heartbeat.rb +140 -0
- data/lib/cosmo/http_server/handler.rb +44 -0
- data/lib/cosmo/http_server/health.rb +39 -0
- data/lib/cosmo/http_server/ping.rb +23 -0
- data/lib/cosmo/http_server.rb +62 -0
- data/lib/cosmo/job/data.rb +17 -12
- data/lib/cosmo/job/enqueuer.rb +53 -0
- data/lib/cosmo/job/failure.rb +52 -0
- data/lib/cosmo/job/limit.rb +2 -2
- data/lib/cosmo/job/processor.rb +110 -73
- data/lib/cosmo/job/requeue.rb +20 -0
- data/lib/cosmo/job/stream_filter.rb +54 -0
- data/lib/cosmo/job.rb +10 -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/middleware/sentry.rb +61 -0
- data/lib/cosmo/middleware/totals.rb +29 -0
- data/lib/cosmo/middleware.rb +7 -0
- data/lib/cosmo/processor.rb +49 -8
- 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/message.rb +2 -1
- data/lib/cosmo/stream/processor.rb +12 -1
- data/lib/cosmo/utils/hash.rb +7 -8
- data/lib/cosmo/utils/json.rb +1 -1
- data/lib/cosmo/utils/overrides.rb +16 -10
- data/lib/cosmo/utils/thread_pool.rb +5 -0
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +79 -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 +43 -5
- data/lib/cosmo/web/controllers/metrics.rb +48 -0
- data/lib/cosmo/web/helpers/application.rb +20 -1
- data/lib/cosmo/web/views/batches/_table.erb +11 -0
- data/lib/cosmo/web/views/batches/index.erb +1 -1
- data/lib/cosmo/web/views/jobs/_busy.erb +70 -4
- data/lib/cosmo/web/views/jobs/_dead.erb +29 -16
- data/lib/cosmo/web/views/jobs/_enqueued.erb +10 -0
- data/lib/cosmo/web/views/jobs/_processes.erb +145 -0
- data/lib/cosmo/web/views/jobs/_scheduled.erb +1 -1
- 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/busy.erb +1 -1
- data/lib/cosmo/web/views/jobs/dead.erb +2 -2
- data/lib/cosmo/web/views/jobs/enqueued.erb +1 -1
- data/lib/cosmo/web/views/jobs/index.erb +7 -1
- data/lib/cosmo/web/views/jobs/scheduled.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/views/streams/index.erb +1 -1
- data/lib/cosmo/web.rb +5 -0
- data/lib/cosmo.rb +51 -0
- data/sig/cosmo/api/counter.rbs +3 -6
- data/sig/cosmo/api/cron/entry.rbs +2 -0
- data/sig/cosmo/api/cron.rbs +4 -2
- data/sig/cosmo/api/job.rbs +12 -0
- data/sig/cosmo/api/kv.rbs +7 -1
- data/sig/cosmo/api/registry.rbs +30 -0
- data/sig/cosmo/api/stats/busy.rbs +31 -0
- data/sig/cosmo/api/stats/metrics.rbs +42 -0
- data/sig/cosmo/api/stats/processes.rbs +19 -0
- data/sig/cosmo/api/stats/totals.rbs +18 -0
- data/sig/cosmo/batch/counters.rbs +10 -0
- data/sig/cosmo/batch.rbs +3 -4
- data/sig/cosmo/cli.rbs +6 -0
- data/sig/cosmo/client.rbs +15 -2
- data/sig/cosmo/config/settings.rbs +31 -0
- data/sig/cosmo/config.rbs +59 -5
- data/sig/cosmo/engine.rbs +29 -0
- data/sig/cosmo/heartbeat.rbs +38 -0
- data/sig/cosmo/http_server/handler.rbs +17 -0
- data/sig/cosmo/http_server/health.rbs +21 -0
- data/sig/cosmo/http_server/ping.rbs +14 -0
- data/sig/cosmo/http_server.rbs +22 -0
- data/sig/cosmo/job/data.rbs +5 -3
- data/sig/cosmo/job/enqueuer.rbs +11 -0
- data/sig/cosmo/job/failure.rbs +28 -0
- data/sig/cosmo/job/processor.rbs +18 -4
- data/sig/cosmo/job/requeue.rbs +9 -0
- data/sig/cosmo/job/stream_filter.rbs +25 -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 +14 -1
- data/sig/cosmo/publisher.rbs +1 -9
- data/sig/cosmo/services.rbs +18 -0
- data/sig/cosmo/stream/message.rbs +3 -1
- data/sig/cosmo/stream/processor.rbs +4 -0
- data/sig/cosmo/utils/hash.rbs +2 -3
- data/sig/cosmo/utils/thread_pool.rbs +3 -0
- data/sig/cosmo.rbs +15 -0
- metadata +62 -22
- data/lib/cosmo/api/busy.rb +0 -66
- data/lib/cosmo/sentry/auto.rb +0 -6
- data/lib/cosmo/sentry/job_processor_middleware.rb +0 -66
- data/sig/cosmo/api/busy.rbs +0 -35
data/lib/cosmo/job/processor.rb
CHANGED
|
@@ -5,27 +5,63 @@ 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::
|
|
32
|
+
API::Stats::Busy.instance
|
|
33
|
+
API::Stats::Totals.instance
|
|
14
34
|
Limit.instance
|
|
35
|
+
Config.server_middleware
|
|
15
36
|
|
|
16
37
|
jobs_config = Config.dig(:consumers, :jobs)
|
|
17
38
|
jobs_config&.each do |stream_name, config|
|
|
18
|
-
next if stream_name ==
|
|
39
|
+
next if stream_name == StreamFilter::SCHEDULED # scheduled jobs are handled in schedule_loop
|
|
40
|
+
next unless filter.include?(stream_name)
|
|
19
41
|
|
|
20
42
|
@consumers << subscribe(stream_name, config)
|
|
21
43
|
end
|
|
44
|
+
|
|
45
|
+
log_subscriptions
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def log_subscriptions
|
|
49
|
+
return if @consumers.empty?
|
|
50
|
+
|
|
51
|
+
names = @consumers.map { |(_, config, _)| config[:consumer] }
|
|
52
|
+
names << consumer_name(StreamFilter::SCHEDULED) if scheduler? && scheduled_config
|
|
53
|
+
Logger.info "subscribed: #{names.join(", ")}#{" (scheduler off)" unless scheduler?}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def scheduled_config
|
|
57
|
+
Services.scheduled_consumer
|
|
22
58
|
end
|
|
23
59
|
|
|
24
60
|
def schedule_loop # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Metrics/AbcSize
|
|
25
|
-
config =
|
|
61
|
+
config = scheduled_config
|
|
26
62
|
return unless config
|
|
27
63
|
|
|
28
|
-
subscription, = subscribe(
|
|
64
|
+
subscription, = subscribe(StreamFilter::SCHEDULED, config)
|
|
29
65
|
while running?
|
|
30
66
|
break unless running?
|
|
31
67
|
|
|
@@ -33,13 +69,18 @@ module Cosmo
|
|
|
33
69
|
timeout = ENV.fetch("COSMO_JOBS_SCHEDULER_FETCH_TIMEOUT", 5).to_f
|
|
34
70
|
messages = fetch(subscription, batch_size: 100, timeout:)
|
|
35
71
|
messages&.each do |message|
|
|
36
|
-
headers = message.header.except(
|
|
72
|
+
headers = message.header.except(*DISPATCH_HEADERS)
|
|
37
73
|
stream, subject, execute_at = message.header.values_at("X-Stream", "X-Subject", "X-Execute-At")
|
|
38
74
|
headers["Nats-Expected-Stream"] = stream
|
|
75
|
+
scheduler = message.header["Nats-Scheduler"]
|
|
76
|
+
if scheduler
|
|
77
|
+
headers["X-Scheduled-By"] = scheduler
|
|
78
|
+
headers["X-Enqueued-At"] = message.metadata.timestamp.to_f.to_s
|
|
79
|
+
end
|
|
39
80
|
execute_at = execute_at.to_i
|
|
40
81
|
|
|
41
82
|
if now >= execute_at
|
|
42
|
-
client.publish(subject, message.data,
|
|
83
|
+
client.publish(subject, message.data, header: headers)
|
|
43
84
|
message.ack
|
|
44
85
|
else
|
|
45
86
|
message.nak(delay: Config.to_ns(execute_at - now))
|
|
@@ -49,6 +90,7 @@ module Cosmo
|
|
|
49
90
|
# to escape #each and kill this thread — schedule_loop only runs once per processor,
|
|
50
91
|
# so an unhandled exception would silently stop all future scheduled-job dispatch.
|
|
51
92
|
Logger.error e
|
|
93
|
+
Cosmo.handle_error(e, { source: :scheduler, subject: message.subject })
|
|
52
94
|
message.nak rescue nil
|
|
53
95
|
end
|
|
54
96
|
|
|
@@ -56,58 +98,49 @@ module Cosmo
|
|
|
56
98
|
end
|
|
57
99
|
end
|
|
58
100
|
|
|
59
|
-
def process(messages, _) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
101
|
+
def process(messages, _) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
60
102
|
message = messages.first
|
|
61
103
|
Logger.debug "received messages #{messages.inspect}"
|
|
62
104
|
data = Utils::Json.parse(message.data)
|
|
63
|
-
unless data
|
|
64
|
-
Logger.error ArgumentError.new("malformed payload")
|
|
65
|
-
move_message(message)
|
|
66
|
-
return
|
|
67
|
-
end
|
|
105
|
+
return reject_message(message, ArgumentError.new("malformed payload")) unless data
|
|
68
106
|
|
|
69
107
|
worker_class = Utils::String.safe_constantize(data[:class])
|
|
70
108
|
unless worker_class
|
|
71
|
-
|
|
72
|
-
move_message(message, data)
|
|
109
|
+
reject_message(message, ArgumentError.new("#{data[:class]} class not found"), data)
|
|
73
110
|
notify_batch(data, success: false)
|
|
74
111
|
return
|
|
75
112
|
end
|
|
76
113
|
|
|
77
|
-
|
|
78
|
-
slot = acquire_concurrency_slot(worker_class, message, data)
|
|
79
|
-
return if slot == false
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
duration = worker_class.default_options[:limit]&.dig(:duration)&.to_i
|
|
83
|
-
|
|
84
|
-
with_stats(message) do
|
|
114
|
+
begin
|
|
85
115
|
sw = stopwatch
|
|
86
116
|
Logger.with(jid: data[:jid])
|
|
87
117
|
Logger.info "start"
|
|
88
118
|
|
|
89
119
|
instance = build_worker(worker_class, data, message)
|
|
90
|
-
|
|
120
|
+
Config.server_middleware.invoke(instance, data, message) do
|
|
121
|
+
perform_job(instance, data: data, message: message)
|
|
122
|
+
end
|
|
91
123
|
|
|
92
124
|
message.ack
|
|
93
125
|
notify_batch(data, success: true)
|
|
94
126
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "done" }
|
|
95
|
-
|
|
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]" }
|
|
96
130
|
rescue Timeout::Error => e
|
|
97
131
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[timeout]" }
|
|
98
|
-
|
|
99
|
-
|
|
132
|
+
Cosmo.handle_error(e, error_context(message, data))
|
|
133
|
+
handle_failure(worker_class, message, data, e)
|
|
100
134
|
rescue StandardError => e
|
|
101
135
|
Logger.debug e
|
|
102
136
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[error]" }
|
|
103
|
-
|
|
104
|
-
|
|
137
|
+
Cosmo.handle_error(e, error_context(message, data))
|
|
138
|
+
handle_failure(worker_class, message, data, e)
|
|
105
139
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
106
140
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[exception]" }
|
|
107
141
|
raise
|
|
108
142
|
end
|
|
109
143
|
ensure
|
|
110
|
-
Limit.instance.release(slot) if slot
|
|
111
144
|
Logger.without(:jid)
|
|
112
145
|
Logger.debug "processed message #{message.inspect}"
|
|
113
146
|
end
|
|
@@ -115,34 +148,13 @@ module Cosmo
|
|
|
115
148
|
def build_worker(worker_class, data, message)
|
|
116
149
|
worker_class.new.tap do |worker|
|
|
117
150
|
worker.jid = data[:jid]
|
|
118
|
-
worker.enqueued_at = message
|
|
151
|
+
worker.enqueued_at = enqueued_at(message)
|
|
119
152
|
worker.attempt = message.metadata.num_delivered
|
|
120
|
-
worker.scheduled_by = message
|
|
153
|
+
worker.scheduled_by = scheduled_by(message)
|
|
121
154
|
worker.batch_id = data[:batch_id]
|
|
122
155
|
end
|
|
123
156
|
end
|
|
124
157
|
|
|
125
|
-
# Tries to acquire a concurrency slot for the job.
|
|
126
|
-
# Returns the slot key (String) on success, or false if all slots are
|
|
127
|
-
# taken (a message is NAK'd with a delay of +retry_in+ before returning
|
|
128
|
-
def acquire_concurrency_slot(worker_class, message, data)
|
|
129
|
-
options = worker_class.concurrency_options
|
|
130
|
-
key = worker_class.concurrency_key(data[:args])
|
|
131
|
-
|
|
132
|
-
slot = Limit.instance.acquire(key, jid: data[:jid], limit: options[:limit], duration: options[:duration])
|
|
133
|
-
return slot if slot
|
|
134
|
-
|
|
135
|
-
message.nak(delay: Config.to_ns(options[:retry_in]))
|
|
136
|
-
Logger.debug "concurrency limit reached for #{data[:class]}, re-queueing back #{data[:jid]}"
|
|
137
|
-
false
|
|
138
|
-
rescue NATS::Error => e
|
|
139
|
-
# Unexpected KV failure (e.g. transient NATS error). NAK immediately so
|
|
140
|
-
# the message is retried rather than stuck in-flight until ack_wait expires.
|
|
141
|
-
Logger.error e
|
|
142
|
-
message.nak
|
|
143
|
-
false
|
|
144
|
-
end
|
|
145
|
-
|
|
146
158
|
def handle_failure(worker_class, message, data, exception) # rubocop:disable Naming/PredicateMethod
|
|
147
159
|
current_attempt = message.metadata.num_delivered
|
|
148
160
|
desired_retries = data[:retry].to_i + 1
|
|
@@ -154,7 +166,7 @@ module Cosmo
|
|
|
154
166
|
end
|
|
155
167
|
|
|
156
168
|
warn_capped(message, data, capped_at) if capped_at
|
|
157
|
-
data[:dead] ? move_message(message, data) : drop_message(message, data)
|
|
169
|
+
data[:dead] ? move_message(message, data, exception) : drop_message(message, data)
|
|
158
170
|
notify_batch(data, success: false)
|
|
159
171
|
true
|
|
160
172
|
end
|
|
@@ -196,6 +208,7 @@ module Cosmo
|
|
|
196
208
|
delay.is_a?(Numeric) && delay.positive? ? delay : default_retry_delay(current_attempt)
|
|
197
209
|
rescue StandardError => e
|
|
198
210
|
Logger.error e
|
|
211
|
+
Cosmo.handle_error(e, { source: :retry_in, class: data[:class], jid: data[:jid] })
|
|
199
212
|
default_retry_delay(current_attempt)
|
|
200
213
|
end
|
|
201
214
|
|
|
@@ -207,26 +220,62 @@ module Cosmo
|
|
|
207
220
|
config = config.dup
|
|
208
221
|
config[:batch_size] = 1
|
|
209
222
|
config[:stream] = stream_name
|
|
210
|
-
config[:consumer] =
|
|
223
|
+
config[:consumer] = consumer_name(stream_name)
|
|
211
224
|
subscription = client.subscribe(config[:subject], config[:consumer], config.except(:subject, :priority, :stream, :batch_size, :consumer))
|
|
212
225
|
[subscription, config, nil]
|
|
213
226
|
end
|
|
214
227
|
|
|
215
228
|
def drop_message(message, data)
|
|
216
229
|
message.term
|
|
217
|
-
Logger.debug "job dropped #{data
|
|
230
|
+
Logger.debug "job dropped #{data&.dig(:jid)}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Logs why a message can't be processed at all and parks it in the DLQ.
|
|
234
|
+
def reject_message(message, error, data = nil)
|
|
235
|
+
Logger.error error
|
|
236
|
+
Cosmo.handle_error(error, error_context(message, data, source: :reject))
|
|
237
|
+
move_message(message, data, error)
|
|
238
|
+
end
|
|
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))
|
|
218
243
|
end
|
|
219
244
|
|
|
220
|
-
def move_message(message, data = nil)
|
|
245
|
+
def move_message(message, data = nil, exception = nil)
|
|
246
|
+
return drop_message(message, data) unless Config.dead.enabled
|
|
247
|
+
|
|
221
248
|
klass = data ? Utils::String.underscore(data[:class]) : "default"
|
|
222
249
|
headers = { "X-Stream" => message.metadata.stream, "X-Subject" => message.subject }
|
|
250
|
+
headers.merge!(Failure.headers(exception)) if exception
|
|
223
251
|
Client.instance.publish("jobs.dead.#{klass}", message.data, header: headers)
|
|
224
252
|
message.ack
|
|
225
253
|
Logger.debug "job moved #{data&.dig(:jid)} to DLQ"
|
|
226
254
|
end
|
|
227
255
|
|
|
228
256
|
def scheduler?
|
|
229
|
-
true
|
|
257
|
+
@options.fetch(:scheduler, true)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# +Nats-Scheduler+ on a message NATS fired directly into a job stream, and +X-Scheduled-By+
|
|
261
|
+
# on one the scheduler dispatched onward, which may not carry the reserved header.
|
|
262
|
+
def scheduled_by(message)
|
|
263
|
+
header = message.header or return
|
|
264
|
+
|
|
265
|
+
header["X-Scheduled-By"] || header["Nats-Scheduler"]
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# A cron message is enqueued when NATS fires it, not when the scheduler dispatches it on, so
|
|
269
|
+
# the firing forwards its own timestamp in +X-Enqueued-At+ to survive the re-publish.
|
|
270
|
+
def enqueued_at(message)
|
|
271
|
+
forwarded = message.header&.dig("X-Enqueued-At")
|
|
272
|
+
|
|
273
|
+
forwarded ? Time.at(forwarded.to_f) : message.metadata.timestamp
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Durable and per stream, so every process pulls from the same consumer and shares the work.
|
|
277
|
+
def consumer_name(stream_name)
|
|
278
|
+
"consumer-#{stream_name}"
|
|
230
279
|
end
|
|
231
280
|
|
|
232
281
|
def consumers
|
|
@@ -242,26 +291,14 @@ module Cosmo
|
|
|
242
291
|
ENV.fetch("COSMO_JOBS_FETCH_TIMEOUT", 0.1).to_f
|
|
243
292
|
end
|
|
244
293
|
|
|
245
|
-
def with_stats(message, &block)
|
|
246
|
-
API::Busy.instance.with(message) do
|
|
247
|
-
API::Counter.instance.with(&block)
|
|
248
|
-
end
|
|
249
|
-
end
|
|
250
|
-
|
|
251
294
|
# @param job_instance [Cosmo::Job]
|
|
252
295
|
# @param data [Hash]
|
|
253
296
|
# @param message [NATS::Msg]
|
|
254
|
-
# @param duration [Float, nil]
|
|
255
297
|
#
|
|
256
|
-
# rubocop:disable Lint/UnusedMethodArgument
|
|
257
|
-
def perform_job(job_instance, data:, message
|
|
258
|
-
|
|
259
|
-
Timeout.timeout(duration) { job_instance.perform(*data[:args]) }
|
|
260
|
-
else
|
|
261
|
-
job_instance.perform(*data[:args])
|
|
262
|
-
end
|
|
298
|
+
# rubocop:disable-next Lint/UnusedMethodArgument
|
|
299
|
+
def perform_job(job_instance, data:, message:)
|
|
300
|
+
job_instance.perform(*data[:args])
|
|
263
301
|
end
|
|
264
|
-
# rubocop:enable Lint/UnusedMethodArgument
|
|
265
302
|
end
|
|
266
303
|
end
|
|
267
304
|
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
|
|
@@ -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,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "cosmo/job/data"
|
|
4
|
+
require "cosmo/job/enqueuer"
|
|
5
|
+
require "cosmo/job/requeue"
|
|
6
|
+
require "cosmo/job/failure"
|
|
7
|
+
require "cosmo/job/stream_filter"
|
|
4
8
|
require "cosmo/job/limit"
|
|
5
9
|
require "cosmo/job/processor"
|
|
6
10
|
|
|
@@ -94,30 +98,14 @@ module Cosmo
|
|
|
94
98
|
end
|
|
95
99
|
|
|
96
100
|
def perform(*args, async: true, **options)
|
|
97
|
-
|
|
98
|
-
options
|
|
99
|
-
data = Data.new(name, args, default_options.merge(options))
|
|
100
|
-
unless async
|
|
101
|
-
payload = Utils::Json.parse(data.to_args[1])
|
|
102
|
-
raise ArgumentError, "Cannot parse payload" unless payload
|
|
103
|
-
|
|
104
|
-
new.perform(*payload[:args])
|
|
105
|
-
return
|
|
106
|
-
end
|
|
101
|
+
options = default_options.merge(options)
|
|
102
|
+
return Enqueuer.enqueue(name, args, options, batch: Batch.current) if async
|
|
107
103
|
|
|
108
|
-
|
|
109
|
-
|
|
104
|
+
payload = Utils::Json.parse(Data.new(name, args, options).to_json)
|
|
105
|
+
raise ArgumentError, "Cannot parse payload" unless payload
|
|
110
106
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
# if it never actually made it onto the stream, so the batch doesn't
|
|
114
|
-
# hang waiting for a completion that will never arrive.
|
|
115
|
-
def publish(data, batch)
|
|
116
|
-
batch&.register_job!
|
|
117
|
-
Publisher.publish_job(data)
|
|
118
|
-
rescue StandardError
|
|
119
|
-
batch&.rollback_job!
|
|
120
|
-
raise
|
|
107
|
+
new.perform(*payload[:args])
|
|
108
|
+
nil
|
|
121
109
|
end
|
|
122
110
|
|
|
123
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
|