cosmonats 0.7.0 → 0.9.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 +219 -113
- data/lib/cosmo/active_job/adapter.rb +1 -2
- data/lib/cosmo/api/counter.rb +17 -17
- data/lib/cosmo/api/cron/entry.rb +10 -11
- data/lib/cosmo/api/cron.rb +6 -3
- data/lib/cosmo/api/job.rb +1 -1
- data/lib/cosmo/api/kv.rb +3 -3
- data/lib/cosmo/api/registry.rb +1 -1
- data/lib/cosmo/api/stats/busy.rb +6 -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 +18 -2
- data/lib/cosmo/api/stream.rb +7 -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 +37 -14
- 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 +32 -58
- 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/utils.rb +0 -1
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +271 -571
- data/lib/cosmo/web/assets/app.js +158 -0
- data/lib/cosmo/web/assets/fonts/OFL.txt +92 -0
- data/lib/cosmo/web/assets/fonts/geist-mono.woff2 +0 -0
- data/lib/cosmo/web/assets/fonts/geist.woff2 +0 -0
- data/lib/cosmo/web/assets/logo.svg +10 -0
- data/lib/cosmo/web/cache.rb +75 -0
- data/lib/cosmo/web/chart.rb +122 -0
- data/lib/cosmo/web/circuit.rb +55 -0
- data/lib/cosmo/web/context.rb +0 -4
- data/lib/cosmo/web/controllers/application.rb +16 -0
- data/lib/cosmo/web/controllers/batches.rb +1 -0
- data/lib/cosmo/web/controllers/crons.rb +17 -2
- data/lib/cosmo/web/controllers/jobs.rb +59 -54
- data/lib/cosmo/web/controllers/metrics.rb +49 -0
- data/lib/cosmo/web/controllers/overview.rb +70 -0
- data/lib/cosmo/web/controllers/processes.rb +33 -0
- data/lib/cosmo/web/controllers/streams.rb +26 -28
- data/lib/cosmo/web/helpers/application.rb +67 -0
- data/lib/cosmo/web/renderer.rb +19 -6
- data/lib/cosmo/web/throughput.rb +104 -0
- data/lib/cosmo/web/views/batches/_table.erb +42 -43
- data/lib/cosmo/web/views/batches/index.erb +3 -10
- data/lib/cosmo/web/views/crons/_table.erb +32 -53
- data/lib/cosmo/web/views/crons/index.erb +2 -9
- data/lib/cosmo/web/views/jobs/_args.erb +5 -0
- data/lib/cosmo/web/views/jobs/_busy.erb +25 -121
- data/lib/cosmo/web/views/jobs/_dead.erb +42 -82
- data/lib/cosmo/web/views/jobs/_enqueued.erb +33 -113
- data/lib/cosmo/web/views/jobs/_panel.erb +29 -0
- data/lib/cosmo/web/views/jobs/_queue_row.erb +18 -0
- data/lib/cosmo/web/views/jobs/_queues.erb +20 -0
- data/lib/cosmo/web/views/jobs/_scheduled.erb +20 -54
- data/lib/cosmo/web/views/jobs/index.erb +4 -17
- data/lib/cosmo/web/views/layout.erb +73 -22
- data/lib/cosmo/web/views/metrics/_chart.erb +41 -0
- data/lib/cosmo/web/views/metrics/_panel.erb +13 -0
- data/lib/cosmo/web/views/metrics/_table.erb +25 -0
- data/lib/cosmo/web/views/metrics/index.erb +9 -0
- data/lib/cosmo/web/views/overview/_badges.erb +3 -0
- data/lib/cosmo/web/views/overview/_kpis.erb +19 -0
- data/lib/cosmo/web/views/overview/_running.erb +18 -0
- data/lib/cosmo/web/views/overview/_throughput.erb +50 -0
- data/lib/cosmo/web/views/overview/_workers.erb +25 -0
- data/lib/cosmo/web/views/overview/index.erb +28 -0
- data/lib/cosmo/web/views/processes/_table.erb +67 -0
- data/lib/cosmo/web/views/processes/index.erb +1 -0
- data/lib/cosmo/web/views/shared/_pager.erb +22 -0
- data/lib/cosmo/web/views/shared/unavailable.erb +7 -0
- data/lib/cosmo/web/views/streams/_info.erb +28 -86
- data/lib/cosmo/web/views/streams/_list.erb +7 -0
- data/lib/cosmo/web/views/streams/_pause_banner.erb +8 -16
- data/lib/cosmo/web/views/streams/_stream.erb +52 -0
- data/lib/cosmo/web/views/streams/index.erb +3 -11
- data/lib/cosmo/web/views/streams/info.erb +3 -11
- data/lib/cosmo/web.rb +67 -33
- data/lib/cosmo.rb +38 -0
- data/sig/cosmo/api/counter.rbs +2 -3
- data/sig/cosmo/api/cron/entry.rbs +2 -1
- data/sig/cosmo/api/stats/busy.rbs +3 -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/api/stats.rbs +4 -0
- data/sig/cosmo/api/stream.rbs +2 -0
- 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 +4 -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 +60 -20
- data/lib/cosmo/sentry/auto.rb +0 -6
- data/lib/cosmo/utils/overrides.rb +0 -206
- data/lib/cosmo/utils/warnings.rb +0 -17
- data/lib/cosmo/web/controllers/actions.rb +0 -16
- data/lib/cosmo/web/views/actions/index.erb +0 -7
- data/lib/cosmo/web/views/jobs/_processes.erb +0 -131
- data/lib/cosmo/web/views/jobs/_stats.erb +0 -73
- data/lib/cosmo/web/views/jobs/_tabs.erb +0 -8
- data/lib/cosmo/web/views/jobs/busy.erb +0 -18
- data/lib/cosmo/web/views/jobs/dead.erb +0 -18
- data/lib/cosmo/web/views/jobs/enqueued.erb +0 -18
- data/lib/cosmo/web/views/jobs/scheduled.erb +0 -18
- data/lib/cosmo/web/views/streams/_stream_row.erb +0 -42
- data/lib/cosmo/web/views/streams/_table.erb +0 -25
data/lib/cosmo/config.rb
CHANGED
|
@@ -2,31 +2,68 @@
|
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
require "forwardable"
|
|
5
|
+
require "cosmo/config/settings"
|
|
5
6
|
|
|
6
7
|
module Cosmo
|
|
7
8
|
class Config < ::Hash
|
|
8
9
|
NANO = 1_000_000_000
|
|
9
10
|
DEFAULT_PATH = "config/cosmo.yml"
|
|
11
|
+
DEFAULTS_FILE = File.expand_path(DEFAULT_PATH, __dir__)
|
|
12
|
+
SERVICE_STREAMS = %i[scheduled dead].freeze
|
|
10
13
|
|
|
11
14
|
class << self
|
|
12
15
|
extend Forwardable
|
|
13
16
|
|
|
14
|
-
delegate %i[[] fetch dig
|
|
17
|
+
delegate %i[[] fetch dig load server_middleware client_middleware error_handlers replicas scheduled dead batches metrics] => :instance
|
|
15
18
|
end
|
|
16
19
|
|
|
17
20
|
def self.to_ns(seconds)
|
|
18
21
|
(seconds.to_f * NANO).to_i
|
|
19
22
|
end
|
|
20
23
|
|
|
24
|
+
def self.read(path)
|
|
25
|
+
Utils::Hash.symbolize_keys!(YAML.load_file(path, aliases: true) || {})
|
|
26
|
+
end
|
|
27
|
+
|
|
21
28
|
def self.parse_file(path)
|
|
22
|
-
|
|
29
|
+
read(path).tap { normalize!(_1) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The built-in {DEFAULTS_FILE} with +user+ deep-merged over it. Job streams are the one list that is not merged:
|
|
33
|
+
# a user who lists any in +setup.jobs+ gets exactly those, and the built-in +default+ stream and consumer go away.
|
|
34
|
+
# Without +setup.jobs+, +consumers.jobs.default+ still tunes the built-in consumer.
|
|
35
|
+
#
|
|
36
|
+
# @param user [Hash] a parsed config file, not yet normalized
|
|
37
|
+
# @return [Hash] the normalized effective config
|
|
38
|
+
# @raise [ConfigError] when +user+ configures something Cosmo.configure owns
|
|
39
|
+
def self.build(user)
|
|
40
|
+
validate!(user)
|
|
41
|
+
defaults = read(DEFAULTS_FILE)
|
|
42
|
+
if user.dig(:setup, :jobs)
|
|
43
|
+
defaults[:setup].delete(:jobs)
|
|
44
|
+
defaults[:consumers].delete(:jobs)
|
|
45
|
+
end
|
|
46
|
+
Utils::Hash.deep_merge(defaults, user).tap { normalize!(_1) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.validate!(config)
|
|
50
|
+
%i[setup consumers].each do |section|
|
|
51
|
+
jobs = config.dig(section, :jobs)
|
|
52
|
+
name = SERVICE_STREAMS.find { jobs.is_a?(::Hash) && jobs.key?(_1) }
|
|
53
|
+
next unless name
|
|
54
|
+
|
|
55
|
+
raise ConfigError, "`#{section}.jobs.#{name}` is a Cosmo service stream: remove it from cosmo.yml " \
|
|
56
|
+
"and tune it with Cosmo.configure { |config| config.#{name} }"
|
|
57
|
+
end
|
|
58
|
+
return unless config.key?(:batch_expiry)
|
|
59
|
+
|
|
60
|
+
raise ConfigError, "`batch_expiry` moved out of cosmo.yml: Cosmo.configure { |config| config.batches.expiry = ... }"
|
|
23
61
|
end
|
|
24
62
|
|
|
25
63
|
def self.normalize!(config) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
26
64
|
Utils::Hash.symbolize_keys!(config)
|
|
27
65
|
|
|
28
66
|
config[:timeout] = Utils::Duration.parse(config[:timeout]) if config[:timeout]
|
|
29
|
-
config[:batch_expiry] = Utils::Duration.parse(config[:batch_expiry]) if config[:batch_expiry]
|
|
30
67
|
|
|
31
68
|
config[:consumers]&.each_key do |name|
|
|
32
69
|
config[:consumers][name].each do |stream_name, c|
|
|
@@ -46,17 +83,6 @@ module Cosmo
|
|
|
46
83
|
c[:max_age] = to_ns(Utils::Duration.parse(c[:max_age])) if c[:max_age]
|
|
47
84
|
c[:duplicate_window] = to_ns(Utils::Duration.parse(c[:duplicate_window])) if c[:duplicate_window]
|
|
48
85
|
c[:subjects] = c[:subjects].map { |s| format(s, name: name) } if c[:subjects]
|
|
49
|
-
|
|
50
|
-
# The scheduled stream carries every cron template, so it alone enables NATS message
|
|
51
|
-
# scheduling. A schedule can only target a subject its own stream covers, and NATS
|
|
52
|
-
# rejects `discard: new` on any stream with scheduling enabled - keeping both here
|
|
53
|
-
# leaves the streams that actually run jobs free to choose their own discard policy.
|
|
54
|
-
next unless type == :jobs && name.to_s == Job::StreamFilter::SCHEDULED.to_s
|
|
55
|
-
|
|
56
|
-
c[:allow_msg_schedules] = true
|
|
57
|
-
cron_subject = "#{API::Cron::Entry::SUBJECT_PREFIX}.>"
|
|
58
|
-
c[:subjects] = Array(c[:subjects])
|
|
59
|
-
c[:subjects] << cron_subject unless c[:subjects].include?(cron_subject)
|
|
60
86
|
end
|
|
61
87
|
end
|
|
62
88
|
end
|
|
@@ -84,14 +110,113 @@ module Cosmo
|
|
|
84
110
|
@internal ||= {}
|
|
85
111
|
end
|
|
86
112
|
|
|
87
|
-
|
|
88
|
-
|
|
113
|
+
# Replaces the contents with {.build} of the file at +path+ (or of no file), with +overrides+ merged over it, so
|
|
114
|
+
# command-line flags win over the file.
|
|
115
|
+
#
|
|
116
|
+
# @param path [String, nil]
|
|
117
|
+
# @param overrides [Hash, nil] e.g. +{ concurrency: 5 }+
|
|
118
|
+
def load(path = nil, overrides: nil)
|
|
119
|
+
user = path ? self.class.read(path) : {}
|
|
120
|
+
replace(self.class.build(Utils::Hash.deep_merge(user, Hash(overrides))))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
attr_writer :replicas
|
|
124
|
+
|
|
125
|
+
# @return [Integer] replicas for every service stream and bucket, e.g. 3 on a NATS cluster
|
|
126
|
+
def replicas
|
|
127
|
+
@replicas || 1
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @return [Scheduled]
|
|
131
|
+
def scheduled
|
|
132
|
+
@scheduled ||= Scheduled.new(enabled: true)
|
|
89
133
|
end
|
|
90
134
|
|
|
91
|
-
|
|
92
|
-
|
|
135
|
+
# @return [Dead]
|
|
136
|
+
def dead
|
|
137
|
+
@dead ||= Dead.new(enabled: true, max_age: 7 * 86_400, max_msgs: 10_000, max_bytes: -1)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [Batches]
|
|
141
|
+
def batches
|
|
142
|
+
@batches ||= Batches.new(expiry: 3 * 86_400)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [Metrics]
|
|
146
|
+
def metrics
|
|
147
|
+
@metrics ||= Metrics.new(enabled: true, retention: "30d")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Callables given every error Cosmo rescues: failed jobs and stream batches, fetch and scheduler errors, rejected
|
|
151
|
+
# messages. Each is called with +(error, context)+, +context+ being a Hash with a +:source+ (+:job+, +:stream+,
|
|
152
|
+
# +:fetch+, +:scheduler+, +:reject+, +:retry_in+, +:limit+) and what is known there, e.g. a job's payload.
|
|
153
|
+
#
|
|
154
|
+
# Cosmo.configure do |config|
|
|
155
|
+
# config.error_handlers << ->(error, context) { Honeybadger.notify(error, context: context) }
|
|
156
|
+
# end
|
|
157
|
+
#
|
|
158
|
+
# @return [Array<#call>]
|
|
159
|
+
def error_handlers
|
|
160
|
+
@error_handlers ||= []
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# @return [::Logger] the logger Cosmo writes to, see {Logger.instance}
|
|
164
|
+
def logger
|
|
165
|
+
Logger.instance
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Replaces Cosmo's stdout logger, e.g. with +Rails.logger+. Give it +Cosmo::Logger::SimpleFormatter.new+ to keep
|
|
169
|
+
# Cosmo's +jid+/+elapsed+ context in each line.
|
|
170
|
+
#
|
|
171
|
+
# @param logger [::Logger]
|
|
172
|
+
def logger=(logger)
|
|
173
|
+
Logger.instance = logger
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Ignored when +COSMO_LOG_LEVEL+ is set, which always wins.
|
|
177
|
+
#
|
|
178
|
+
# @param level [Symbol, String, Integer] +:trace+, +:debug+, +:info+, +:warn+, +:error+, or +:fatal+
|
|
179
|
+
def log_level=(level)
|
|
180
|
+
Logger.level = level
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# The chain every job execution runs through, starting with the built-in {Middleware::Limit}, {Middleware::Busy},
|
|
184
|
+
# {Middleware::Totals}, and {Middleware::Metrics}. It lives outside the loaded YAML, so {#load} keeps it. Register middleware
|
|
185
|
+
# at boot, before workers start:
|
|
186
|
+
#
|
|
187
|
+
# Cosmo.configure do |config|
|
|
188
|
+
# config.server_middleware do |chain|
|
|
189
|
+
# chain.add MyMiddleware
|
|
190
|
+
# end
|
|
191
|
+
# end
|
|
192
|
+
#
|
|
193
|
+
# @yieldparam chain [Middleware::Chain]
|
|
194
|
+
# @return [Middleware::Chain]
|
|
195
|
+
def server_middleware
|
|
196
|
+
@server_middleware ||= Middleware::Chain.new do |chain|
|
|
197
|
+
chain.add Middleware::Limit
|
|
198
|
+
chain.add Middleware::Busy
|
|
199
|
+
chain.add Middleware::Totals
|
|
200
|
+
chain.add Middleware::Metrics
|
|
201
|
+
end
|
|
202
|
+
yield @server_middleware if block_given?
|
|
203
|
+
@server_middleware
|
|
204
|
+
end
|
|
93
205
|
|
|
94
|
-
|
|
206
|
+
# The chain every job enqueue runs through, empty by default. A middleware is called with
|
|
207
|
+
# +(job_class_name, payload, stream)+ around the publish: it may change +payload+ (the Hash that gets published),
|
|
208
|
+
# or not yield to stop the publish, so +perform_async+ returns +nil+. +stream+ is the stream the job runs on.
|
|
209
|
+
#
|
|
210
|
+
# Cosmo.configure do |config|
|
|
211
|
+
# config.client_middleware { |chain| chain.add RequestIdMiddleware }
|
|
212
|
+
# end
|
|
213
|
+
#
|
|
214
|
+
# @yieldparam chain [Middleware::Chain]
|
|
215
|
+
# @return [Middleware::Chain]
|
|
216
|
+
def client_middleware
|
|
217
|
+
@client_middleware ||= Middleware::Chain.new
|
|
218
|
+
yield @client_middleware if block_given?
|
|
219
|
+
@client_middleware
|
|
95
220
|
end
|
|
96
221
|
end
|
|
97
222
|
end
|
data/lib/cosmo/heartbeat.rb
CHANGED
|
@@ -26,6 +26,7 @@ module Cosmo
|
|
|
26
26
|
loop do
|
|
27
27
|
sleep(INTERVAL)
|
|
28
28
|
beat
|
|
29
|
+
flush
|
|
29
30
|
end
|
|
30
31
|
end
|
|
31
32
|
self
|
|
@@ -37,6 +38,7 @@ module Cosmo
|
|
|
37
38
|
def stop
|
|
38
39
|
@thread&.kill&.join
|
|
39
40
|
@thread = nil
|
|
41
|
+
flush
|
|
40
42
|
processes.unregister(identity)
|
|
41
43
|
rescue StandardError => e
|
|
42
44
|
Logger.debug "Heartbeat unregister error: #{e.class} #{e.message}"
|
|
@@ -51,6 +53,15 @@ module Cosmo
|
|
|
51
53
|
Logger.debug "Heartbeat error: #{e.class} #{e.message}"
|
|
52
54
|
end
|
|
53
55
|
|
|
56
|
+
# Writes the job metrics recorded since the last flush.
|
|
57
|
+
#
|
|
58
|
+
# @return [void]
|
|
59
|
+
def flush
|
|
60
|
+
API::Stats::Metrics.instance.flush if Config.metrics.enabled
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
Logger.debug "Metrics flush error: #{e.class} #{e.message}"
|
|
63
|
+
end
|
|
64
|
+
|
|
54
65
|
# @return [String]
|
|
55
66
|
def identity
|
|
56
67
|
@identity ||= "#{Socket.gethostname}-#{::Process.pid}"
|
data/lib/cosmo/job/data.rb
CHANGED
|
@@ -36,8 +36,19 @@ module Cosmo
|
|
|
36
36
|
@at ? :scheduled : @options[:stream]
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
# @return [String] the subject the job is published to: the custom +subject:+ option, or the one derived
|
|
40
|
+
# from its stream and class
|
|
41
|
+
def subject
|
|
42
|
+
@subject || subject_for(stream)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Hash{String => String, Integer}] dedup id, plus where to dispatch a delayed job once it is due
|
|
46
|
+
def headers
|
|
47
|
+
headers = { NATS::JetStream::Header::MSG_ID => jid }
|
|
48
|
+
return headers unless @at
|
|
49
|
+
|
|
50
|
+
target = stream(target: true)
|
|
51
|
+
headers.merge("X-Execute-At" => @at.to_i, "X-Stream" => target, "X-Subject" => subject_for(target))
|
|
41
52
|
end
|
|
42
53
|
|
|
43
54
|
def as_json
|
|
@@ -49,18 +60,12 @@ module Cosmo
|
|
|
49
60
|
Utils::Json.dump(as_json)
|
|
50
61
|
end
|
|
51
62
|
|
|
52
|
-
def to_args
|
|
53
|
-
headers = { "Nats-Msg-Id" => jid }
|
|
54
|
-
if @at
|
|
55
|
-
headers.merge!("X-Execute-At" => @at.to_i,
|
|
56
|
-
"X-Stream" => stream(target: true),
|
|
57
|
-
"X-Subject" => subject(target: true).join("."))
|
|
58
|
-
end
|
|
59
|
-
[@subject || subject.join("."), to_json, { stream: stream, header: headers }]
|
|
60
|
-
end
|
|
61
|
-
|
|
62
63
|
private
|
|
63
64
|
|
|
65
|
+
def subject_for(stream)
|
|
66
|
+
"jobs.#{stream}.#{Utils::String.underscore(@class_name)}"
|
|
67
|
+
end
|
|
68
|
+
|
|
64
69
|
def validate!
|
|
65
70
|
raise ArgumentError, "stream is not provided" unless @options[:stream]
|
|
66
71
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
module Job
|
|
5
|
+
# The one path a job takes onto its stream, shared by {Job::ClassMethods#perform} and the ActiveJob adapter.
|
|
6
|
+
module Enqueuer
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Publishes the job through {Config#client_middleware}.
|
|
10
|
+
#
|
|
11
|
+
# @param class_name [String]
|
|
12
|
+
# @param args [Array]
|
|
13
|
+
# @param options [Hash] job options, see {Data}
|
|
14
|
+
# @param batch [Batch, nil] the batch the job joins; it is reserved a pending slot before the publish
|
|
15
|
+
# (see Batch#jobs) and released again when the publish fails or a middleware stops it, so the batch never
|
|
16
|
+
# waits on a lost job
|
|
17
|
+
# @return [String, nil] the job's jid, or nil when a client middleware stopped the publish
|
|
18
|
+
# @raise [StreamNotFoundError] when the target stream does not exist
|
|
19
|
+
# @raise [SchedulingDisabledError] for a delayed job while scheduling is turned off
|
|
20
|
+
def enqueue(class_name, args, options, batch: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
|
21
|
+
options = options.merge(batch_id: batch.bid) if batch
|
|
22
|
+
data = Data.new(class_name, args, options)
|
|
23
|
+
raise SchedulingDisabledError if data.stream.to_s == Services::SCHEDULED && !Config.scheduled.enabled
|
|
24
|
+
|
|
25
|
+
batch&.register_job!
|
|
26
|
+
begin
|
|
27
|
+
published = pipe(class_name, data)
|
|
28
|
+
rescue StandardError
|
|
29
|
+
batch&.rollback_job!
|
|
30
|
+
raise
|
|
31
|
+
end
|
|
32
|
+
batch&.rollback_job! unless published
|
|
33
|
+
data.jid if published
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def pipe(class_name, data)
|
|
37
|
+
payload = data.as_json
|
|
38
|
+
published = false
|
|
39
|
+
Config.client_middleware.invoke(class_name, payload, data.stream(target: true)) do
|
|
40
|
+
publish(data, payload)
|
|
41
|
+
published = true
|
|
42
|
+
end
|
|
43
|
+
published
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def publish(data, payload)
|
|
47
|
+
Client.instance.publish(data.subject, Utils::Json.dump(payload), stream: data.stream, header: data.headers)
|
|
48
|
+
rescue NATS::JetStream::Error::NoStreamResponse
|
|
49
|
+
raise StreamNotFoundError, data.stream.to_s
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
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
|
@@ -8,8 +8,8 @@ module Cosmo
|
|
|
8
8
|
# Headers the scheduler consumes rather than forwards. The +X-+ ones address the dispatch
|
|
9
9
|
# itself, and NATS sets +Nats-Scheduler+/+Nats-Schedule-Next+ on a message it fires and
|
|
10
10
|
# rejects a publish that carries them back.
|
|
11
|
-
DISPATCH_HEADERS =
|
|
12
|
-
|
|
11
|
+
DISPATCH_HEADERS = ["X-Stream", "X-Subject", "X-Execute-At", NATS::JetStream::Header::EXPECTED_STREAM,
|
|
12
|
+
NATS::JetStream::Header::SCHEDULER, NATS::JetStream::Header::SCHEDULE_NEXT].freeze
|
|
13
13
|
|
|
14
14
|
# @raise [UnknownJobStreamError] when --streams/--stream or COSMO_JOBS_STREAMS names an unconfigured stream
|
|
15
15
|
def self.validate_options!(options)
|
|
@@ -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
|
|
@@ -70,8 +71,7 @@ module Cosmo
|
|
|
70
71
|
messages&.each do |message|
|
|
71
72
|
headers = message.header.except(*DISPATCH_HEADERS)
|
|
72
73
|
stream, subject, execute_at = message.header.values_at("X-Stream", "X-Subject", "X-Execute-At")
|
|
73
|
-
|
|
74
|
-
scheduler = message.header["Nats-Scheduler"]
|
|
74
|
+
scheduler = message.header[NATS::JetStream::Header::SCHEDULER]
|
|
75
75
|
if scheduler
|
|
76
76
|
headers["X-Scheduled-By"] = scheduler
|
|
77
77
|
headers["X-Enqueued-At"] = message.metadata.timestamp.to_f.to_s
|
|
@@ -79,7 +79,7 @@ module Cosmo
|
|
|
79
79
|
execute_at = execute_at.to_i
|
|
80
80
|
|
|
81
81
|
if now >= execute_at
|
|
82
|
-
client.publish(subject, message.data, header: headers)
|
|
82
|
+
client.publish(subject, message.data, header: headers, stream:)
|
|
83
83
|
message.ack
|
|
84
84
|
else
|
|
85
85
|
message.nak(delay: Config.to_ns(execute_at - now))
|
|
@@ -89,6 +89,7 @@ module Cosmo
|
|
|
89
89
|
# to escape #each and kill this thread — schedule_loop only runs once per processor,
|
|
90
90
|
# so an unhandled exception would silently stop all future scheduled-job dispatch.
|
|
91
91
|
Logger.error e
|
|
92
|
+
Cosmo.handle_error(e, { source: :scheduler, subject: message.subject })
|
|
92
93
|
message.nak rescue nil
|
|
93
94
|
end
|
|
94
95
|
|
|
@@ -96,7 +97,7 @@ module Cosmo
|
|
|
96
97
|
end
|
|
97
98
|
end
|
|
98
99
|
|
|
99
|
-
def process(messages, _) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
100
|
+
def process(messages, _) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
100
101
|
message = messages.first
|
|
101
102
|
Logger.debug "received messages #{messages.inspect}"
|
|
102
103
|
data = Utils::Json.parse(message.data)
|
|
@@ -109,40 +110,36 @@ module Cosmo
|
|
|
109
110
|
return
|
|
110
111
|
end
|
|
111
112
|
|
|
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
|
|
113
|
+
begin
|
|
120
114
|
sw = stopwatch
|
|
121
115
|
Logger.with(jid: data[:jid])
|
|
122
116
|
Logger.info "start"
|
|
123
117
|
|
|
124
118
|
instance = build_worker(worker_class, data, message)
|
|
125
|
-
|
|
119
|
+
Config.server_middleware.invoke(instance, data, message) do
|
|
120
|
+
perform_job(instance, data: data, message: message)
|
|
121
|
+
end
|
|
126
122
|
|
|
127
123
|
message.ack
|
|
128
124
|
notify_batch(data, success: true)
|
|
129
125
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "done" }
|
|
130
|
-
|
|
126
|
+
rescue Requeue => e
|
|
127
|
+
message.nak(delay: Config.to_ns(e.delay))
|
|
128
|
+
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "requeue[#{e.delay}s]" }
|
|
131
129
|
rescue Timeout::Error => e
|
|
132
130
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[timeout]" }
|
|
133
|
-
|
|
134
|
-
|
|
131
|
+
Cosmo.handle_error(e, error_context(message, data))
|
|
132
|
+
handle_failure(worker_class, message, data, e)
|
|
135
133
|
rescue StandardError => e
|
|
136
134
|
Logger.debug e
|
|
137
135
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[error]" }
|
|
138
|
-
|
|
139
|
-
|
|
136
|
+
Cosmo.handle_error(e, error_context(message, data))
|
|
137
|
+
handle_failure(worker_class, message, data, e)
|
|
140
138
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
141
139
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[exception]" }
|
|
142
140
|
raise
|
|
143
141
|
end
|
|
144
142
|
ensure
|
|
145
|
-
Limit.instance.release(slot) if slot
|
|
146
143
|
Logger.without(:jid)
|
|
147
144
|
Logger.debug "processed message #{message.inspect}"
|
|
148
145
|
end
|
|
@@ -157,27 +154,6 @@ module Cosmo
|
|
|
157
154
|
end
|
|
158
155
|
end
|
|
159
156
|
|
|
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
157
|
def handle_failure(worker_class, message, data, exception) # rubocop:disable Naming/PredicateMethod
|
|
182
158
|
current_attempt = message.metadata.num_delivered
|
|
183
159
|
desired_retries = data[:retry].to_i + 1
|
|
@@ -231,6 +207,7 @@ module Cosmo
|
|
|
231
207
|
delay.is_a?(Numeric) && delay.positive? ? delay : default_retry_delay(current_attempt)
|
|
232
208
|
rescue StandardError => e
|
|
233
209
|
Logger.error e
|
|
210
|
+
Cosmo.handle_error(e, { source: :retry_in, class: data[:class], jid: data[:jid] })
|
|
234
211
|
default_retry_delay(current_attempt)
|
|
235
212
|
end
|
|
236
213
|
|
|
@@ -249,16 +226,24 @@ module Cosmo
|
|
|
249
226
|
|
|
250
227
|
def drop_message(message, data)
|
|
251
228
|
message.term
|
|
252
|
-
Logger.debug "job dropped #{data
|
|
229
|
+
Logger.debug "job dropped #{data&.dig(:jid)}"
|
|
253
230
|
end
|
|
254
231
|
|
|
255
232
|
# Logs why a message can't be processed at all and parks it in the DLQ.
|
|
256
233
|
def reject_message(message, error, data = nil)
|
|
257
234
|
Logger.error error
|
|
235
|
+
Cosmo.handle_error(error, error_context(message, data, source: :reject))
|
|
258
236
|
move_message(message, data, error)
|
|
259
237
|
end
|
|
260
238
|
|
|
239
|
+
def error_context(message, data, source: :job)
|
|
240
|
+
meta = message.metadata
|
|
241
|
+
{ source:, stream: meta.stream, subject: message.subject, attempt: meta.num_delivered }.merge(Hash(data))
|
|
242
|
+
end
|
|
243
|
+
|
|
261
244
|
def move_message(message, data = nil, exception = nil)
|
|
245
|
+
return drop_message(message, data) unless Config.dead.enabled
|
|
246
|
+
|
|
262
247
|
klass = data ? Utils::String.underscore(data[:class]) : "default"
|
|
263
248
|
headers = { "X-Stream" => message.metadata.stream, "X-Subject" => message.subject }
|
|
264
249
|
headers.merge!(Failure.headers(exception)) if exception
|
|
@@ -276,7 +261,7 @@ module Cosmo
|
|
|
276
261
|
def scheduled_by(message)
|
|
277
262
|
header = message.header or return
|
|
278
263
|
|
|
279
|
-
header["X-Scheduled-By"] || header[
|
|
264
|
+
header["X-Scheduled-By"] || header[NATS::JetStream::Header::SCHEDULER]
|
|
280
265
|
end
|
|
281
266
|
|
|
282
267
|
# A cron message is enqueued when NATS fires it, not when the scheduler dispatches it on, so
|
|
@@ -305,24 +290,13 @@ module Cosmo
|
|
|
305
290
|
ENV.fetch("COSMO_JOBS_FETCH_TIMEOUT", 0.1).to_f
|
|
306
291
|
end
|
|
307
292
|
|
|
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
293
|
# @param job_instance [Cosmo::Job]
|
|
315
294
|
# @param data [Hash]
|
|
316
295
|
# @param message [NATS::Msg]
|
|
317
|
-
# @param duration [Float, nil]
|
|
318
296
|
#
|
|
319
297
|
# 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
|
|
298
|
+
def perform_job(job_instance, data:, message:)
|
|
299
|
+
job_instance.perform(*data[:args])
|
|
326
300
|
end
|
|
327
301
|
end
|
|
328
302
|
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
|