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/batch.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
require "cosmo/batch/callback"
|
|
5
5
|
require "cosmo/batch/dispatcher"
|
|
6
|
+
require "cosmo/batch/counters"
|
|
6
7
|
|
|
7
8
|
module Cosmo
|
|
8
9
|
# Groups jobs together and runs a callback once they've all finished.
|
|
@@ -39,13 +40,20 @@ module Cosmo
|
|
|
39
40
|
# when everything succeeds. Always call #jobs at least once, even with nothing
|
|
40
41
|
# in it - a batch that never gets a #jobs call never closes and never fires.
|
|
41
42
|
#
|
|
43
|
+
# You can pass your own bid instead of letting one be generated - useful
|
|
44
|
+
# when something else (e.g. an ActiveRecord id) needs to match it:
|
|
45
|
+
#
|
|
46
|
+
# Cosmo::Batch.new(bid: your_id)
|
|
47
|
+
#
|
|
48
|
+
# Same meaning as a plain #new either way - it's still a fresh batch.
|
|
49
|
+
#
|
|
42
50
|
# == How this is actually stored in NATS
|
|
43
51
|
#
|
|
44
52
|
# Every batch has an id (bid, a random hex string). State lives
|
|
45
53
|
# in two places, both keyed by that bid:
|
|
46
54
|
#
|
|
47
|
-
# 1. Three atomic counters (via
|
|
48
|
-
# the
|
|
55
|
+
# 1. Three atomic counters (via Batch::Counters, namespace "batch", in the
|
|
56
|
+
# _cosmobatches stream, which expires them with the KV state below):
|
|
49
57
|
#
|
|
50
58
|
# <bid>.total - how many jobs have ever joined this batch
|
|
51
59
|
# <bid>.pending - how many of them are still running
|
|
@@ -76,8 +84,7 @@ module Cosmo
|
|
|
76
84
|
class Batch
|
|
77
85
|
extend Dispatcher
|
|
78
86
|
|
|
79
|
-
BUCKET = "
|
|
80
|
-
DEFAULT_EXPIRY = "3d"
|
|
87
|
+
BUCKET = "_cosmobatches"
|
|
81
88
|
EVENTS = %i[success complete].freeze
|
|
82
89
|
|
|
83
90
|
def self.current
|
|
@@ -93,17 +100,17 @@ module Cosmo
|
|
|
93
100
|
end
|
|
94
101
|
|
|
95
102
|
def self.counter
|
|
96
|
-
@counter ||=
|
|
103
|
+
@counter ||= Counters.new("batch")
|
|
97
104
|
end
|
|
98
105
|
|
|
99
106
|
def self.kv
|
|
100
|
-
@kv ||= API::KV.new(BUCKET, ttl: Utils::Duration.parse(Config
|
|
107
|
+
@kv ||= API::KV.new(BUCKET, ttl: Utils::Duration.parse(Config.batches.expiry), replicas: Config.replicas)
|
|
101
108
|
end
|
|
102
109
|
|
|
103
110
|
attr_reader :bid, :parent_id
|
|
104
111
|
|
|
105
|
-
def initialize(parent: nil)
|
|
106
|
-
@bid = SecureRandom.hex(8)
|
|
112
|
+
def initialize(bid: nil, parent: nil)
|
|
113
|
+
@bid = bid || SecureRandom.hex(8)
|
|
107
114
|
@parent_id = parent
|
|
108
115
|
kv.set("#{@bid}.meta", Utils::Json.dump({ parent_id: @parent_id, created_at: Time.now.to_i }))
|
|
109
116
|
link(@parent_id) if @parent_id
|
data/lib/cosmo/cli.rb
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
require "optparse"
|
|
5
|
+
require "fileutils"
|
|
5
6
|
|
|
6
7
|
module Cosmo
|
|
7
|
-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
8
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
8
9
|
class CLI
|
|
9
10
|
def self.run
|
|
10
11
|
instance.run
|
|
@@ -14,15 +15,23 @@ module Cosmo
|
|
|
14
15
|
@instance ||= new
|
|
15
16
|
end
|
|
16
17
|
|
|
18
|
+
# @return [Array<String>, nil] the arguments the process was started with, before parsing consumed them
|
|
19
|
+
attr_reader :argv
|
|
20
|
+
|
|
17
21
|
def run
|
|
22
|
+
@argv = ARGV.dup
|
|
18
23
|
flags, command, options = parse
|
|
24
|
+
return run_init if flags[:init]
|
|
19
25
|
return run_setup(flags) if flags[:setup]
|
|
20
26
|
|
|
21
27
|
load_config(flags)
|
|
28
|
+
Engine.processors_for(command).each { _1.validate_options!(options) }
|
|
22
29
|
puts self.class.banner
|
|
23
30
|
boot_application
|
|
24
31
|
require_path(flags[:require])
|
|
25
32
|
Engine.run(command, options)
|
|
33
|
+
rescue Error => e
|
|
34
|
+
abort e.message
|
|
26
35
|
end
|
|
27
36
|
|
|
28
37
|
private
|
|
@@ -50,9 +59,19 @@ module Cosmo
|
|
|
50
59
|
path = default_path if File.exist?(default_path)
|
|
51
60
|
end
|
|
52
61
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
Config.
|
|
62
|
+
overrides = { concurrency: flags[:concurrency], timeout: flags[:timeout] }.compact
|
|
63
|
+
overrides[:http] = { port: flags[:http_port] } if flags[:http_port]
|
|
64
|
+
Config.load(path, overrides:)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def run_init
|
|
68
|
+
path = File.expand_path(Config::DEFAULT_PATH)
|
|
69
|
+
raise Error, "#{Config::DEFAULT_PATH} already exists" if File.exist?(path)
|
|
70
|
+
|
|
71
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
72
|
+
FileUtils.cp(Config::DEFAULTS_FILE, path)
|
|
73
|
+
puts "Created #{Config::DEFAULT_PATH} with Cosmo's defaults"
|
|
74
|
+
exit(0)
|
|
56
75
|
end
|
|
57
76
|
|
|
58
77
|
def run_setup(flags)
|
|
@@ -69,9 +88,12 @@ module Cosmo
|
|
|
69
88
|
first_line ? print("Stream is ready: #{name}") : print(", #{name}")
|
|
70
89
|
first_line = false
|
|
71
90
|
end
|
|
91
|
+
puts unless first_line
|
|
72
92
|
end
|
|
73
93
|
|
|
74
|
-
|
|
94
|
+
services = Services.setup!
|
|
95
|
+
puts "Service streams are ready: #{services.join(", ")}" if services.any?
|
|
96
|
+
|
|
75
97
|
schedules = Config.dig(:setup, :cron)&.reduce(0) do |sum, (name, entry)|
|
|
76
98
|
class_name = entry.delete(:class)
|
|
77
99
|
API::Cron.instance.upsert!(**entry, name: name, class_name: class_name)
|
|
@@ -129,6 +151,10 @@ module Cosmo
|
|
|
129
151
|
flags[:timeout] = arg
|
|
130
152
|
end
|
|
131
153
|
|
|
154
|
+
o.on "-p", "--http-port INT", Integer, "Start HTTP server with /health endpoint on port" do |arg|
|
|
155
|
+
flags[:http_port] = arg
|
|
156
|
+
end
|
|
157
|
+
|
|
132
158
|
o.on "-C", "--config PATH", "Path to config file" do |arg|
|
|
133
159
|
flags[:config_file] = arg
|
|
134
160
|
end
|
|
@@ -137,6 +163,10 @@ module Cosmo
|
|
|
137
163
|
flags[:setup] = true
|
|
138
164
|
end
|
|
139
165
|
|
|
166
|
+
o.on "--init", "Write #{Config::DEFAULT_PATH} with Cosmo's defaults, then exit" do
|
|
167
|
+
flags[:init] = true
|
|
168
|
+
end
|
|
169
|
+
|
|
140
170
|
o.on_tail "-v", "--version", "Print version" do
|
|
141
171
|
puts "Cosmo #{VERSION}"
|
|
142
172
|
exit(0)
|
|
@@ -155,8 +185,16 @@ module Cosmo
|
|
|
155
185
|
OptionParser.new do |o|
|
|
156
186
|
o.banner = "Usage: cosmo jobs [options]"
|
|
157
187
|
|
|
158
|
-
o.on "--
|
|
159
|
-
options[:
|
|
188
|
+
o.on "--streams NAMES", "Only subscribe to these job streams (comma-separated), instead of all configured streams" do |arg|
|
|
189
|
+
options[:streams] = Array(options[:streams]) | arg.split(",")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
o.on "--stream NAME", "Same as --streams, for a single job stream" do |arg|
|
|
193
|
+
options[:streams] = Array(options[:streams]) | [arg]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
o.on "--[no-]scheduler", "Dispatch scheduled jobs (default: yes)" do |arg|
|
|
197
|
+
options[:scheduler] = arg
|
|
160
198
|
end
|
|
161
199
|
|
|
162
200
|
o.on "--subject NAME", "Job's subject" do |arg|
|
|
@@ -214,7 +252,7 @@ module Cosmo
|
|
|
214
252
|
require path
|
|
215
253
|
end
|
|
216
254
|
|
|
217
|
-
# rubocop:disable Layout/TrailingWhitespace,Lint/IneffectiveAccessModifier
|
|
255
|
+
# rubocop:disable-next Layout/TrailingWhitespace,Lint/IneffectiveAccessModifier
|
|
218
256
|
def self.banner
|
|
219
257
|
<<-TEXT
|
|
220
258
|
.#%+:
|
|
@@ -254,7 +292,5 @@ module Cosmo
|
|
|
254
292
|
:.
|
|
255
293
|
TEXT
|
|
256
294
|
end
|
|
257
|
-
# rubocop:enable Layout/TrailingWhitespace,Lint/IneffectiveAccessModifier
|
|
258
295
|
end
|
|
259
|
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
260
296
|
end
|
data/lib/cosmo/client.rb
CHANGED
|
@@ -1,21 +1,49 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "socket"
|
|
3
4
|
require "nats/client"
|
|
4
5
|
require "cosmo/utils/overrides"
|
|
5
6
|
|
|
6
7
|
module Cosmo
|
|
7
8
|
class Client
|
|
9
|
+
# Seconds to wait for a JetStream API reply. Override with COSMO_JS_TIMEOUT.
|
|
10
|
+
JS_TIMEOUT = 5
|
|
11
|
+
# Seconds to wait for the TCP connect+handshake. Override with COSMO_CONNECT_TIMEOUT.
|
|
12
|
+
CONNECT_TIMEOUT = 2
|
|
13
|
+
|
|
8
14
|
def self.instance
|
|
9
15
|
@instance ||= Client.new
|
|
10
16
|
end
|
|
11
17
|
|
|
12
|
-
|
|
18
|
+
# Labels the connection in `nats server report connections` and /connz, so a
|
|
19
|
+
# misbehaving process can be found without cross-referencing host IPs. Derived from
|
|
20
|
+
# the running program, which separates a web process from a `cosmo` worker on the
|
|
21
|
+
# same host. Override with COSMO_CLIENT_NAME.
|
|
22
|
+
def self.default_name
|
|
23
|
+
ENV.fetch("COSMO_CLIENT_NAME") { "cosmo-#{program_name}-#{Socket.gethostname}-#{Process.pid}" }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.js_timeout
|
|
27
|
+
ENV.fetch("COSMO_JS_TIMEOUT", JS_TIMEOUT).to_i
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.connect_timeout
|
|
31
|
+
ENV.fetch("COSMO_CONNECT_TIMEOUT", CONNECT_TIMEOUT).to_i
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# $PROGRAM_NAME is "puma 7.2.1 (tcp://...)" under Puma and a path under the CLI.
|
|
35
|
+
def self.program_name
|
|
36
|
+
File.basename($PROGRAM_NAME.to_s.split.first.to_s, ".*").gsub(/[^\w.-]/, "")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
attr_reader :nc, :js, :name
|
|
13
40
|
|
|
14
|
-
def initialize(nats_url: ENV.fetch("NATS_URL", "nats://localhost:4222"))
|
|
15
|
-
|
|
16
|
-
|
|
41
|
+
def initialize(nats_url: ENV.fetch("NATS_URL", "nats://localhost:4222"), name: self.class.default_name)
|
|
42
|
+
@name = name
|
|
43
|
+
Logger.debug "Connecting to NATS server at #{nats_url} as #{@name}..."
|
|
44
|
+
@nc = NATS.connect(nats_url, name: @name, connect_timeout: self.class.connect_timeout)
|
|
17
45
|
Logger.debug "Connection established"
|
|
18
|
-
@js = @nc.jetstream
|
|
46
|
+
@js = @nc.jetstream(timeout: self.class.js_timeout)
|
|
19
47
|
end
|
|
20
48
|
|
|
21
49
|
def publish(subject, payload, **params)
|
|
@@ -116,6 +144,31 @@ module Cosmo
|
|
|
116
144
|
js.get_msg(stream_name, **options)
|
|
117
145
|
end
|
|
118
146
|
|
|
147
|
+
# The last message on every subject matching +filters+, in one batched direct get (NATS ≥ 2.11). Needs
|
|
148
|
+
# +allow_direct+ on the stream.
|
|
149
|
+
#
|
|
150
|
+
# @param stream_name [String]
|
|
151
|
+
# @param filters [Array<String>] subject filters, wildcards allowed
|
|
152
|
+
# @return [Hash{String => String}] message data by subject
|
|
153
|
+
def last_messages(stream_name, filters) # rubocop:disable Metrics/AbcSize
|
|
154
|
+
inbox = nc.new_inbox
|
|
155
|
+
sub = nc.subscribe(inbox)
|
|
156
|
+
nc.publish("$JS.API.DIRECT.GET.#{stream_name}", Utils::Json.dump({ multi_last: filters, batch: 10_000 }), inbox)
|
|
157
|
+
messages = {}
|
|
158
|
+
loop do
|
|
159
|
+
msg = sub.next_msg(timeout: self.class.js_timeout)
|
|
160
|
+
subject = msg.header&.dig("Nats-Subject")
|
|
161
|
+
break unless subject
|
|
162
|
+
|
|
163
|
+
messages[subject] = msg.data
|
|
164
|
+
end
|
|
165
|
+
messages
|
|
166
|
+
rescue NATS::Timeout
|
|
167
|
+
messages
|
|
168
|
+
ensure
|
|
169
|
+
sub&.unsubscribe
|
|
170
|
+
end
|
|
171
|
+
|
|
119
172
|
def delete_message(name, seq)
|
|
120
173
|
response = nc.request("$JS.API.STREAM.MSG.DELETE.#{name}", JSON.dump({ seq: seq }))
|
|
121
174
|
Utils::Json.parse(response.data, symbolize_names: false)
|
|
@@ -170,7 +223,7 @@ module Cosmo
|
|
|
170
223
|
|
|
171
224
|
# NOTE: KV manager in nats-pure hardcodes the fields it copies into StreamConfig,
|
|
172
225
|
# so `allow_msg_ttl` is never forwarded via create_key_value. Send the raw stream-create API request instead.
|
|
173
|
-
def create_kv_with_msg_ttl(name, **options)
|
|
226
|
+
def create_kv_with_msg_ttl(name, replicas: 1, **options)
|
|
174
227
|
payload = Utils::Json.dump({
|
|
175
228
|
name: "KV_#{name}",
|
|
176
229
|
subjects: ["$KV.#{name}.>"],
|
|
@@ -178,7 +231,8 @@ module Cosmo
|
|
|
178
231
|
allow_direct: true,
|
|
179
232
|
allow_msg_ttl: true,
|
|
180
233
|
allow_rollup_hdrs: true,
|
|
181
|
-
max_msgs_per_subject: 1
|
|
234
|
+
max_msgs_per_subject: 1,
|
|
235
|
+
num_replicas: replicas
|
|
182
236
|
}.merge(options))
|
|
183
237
|
resp = nc.request("$JS.API.STREAM.CREATE.KV_#{name}", payload)
|
|
184
238
|
result = Utils::Json.parse(resp.data, symbolize_names: false)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Cosmo configuration. Every value below is what Cosmo uses when you leave it out,
|
|
2
|
+
# so you can delete anything you don't want to change. Listing your own job streams under setup.jobs
|
|
3
|
+
# replaces the `default` stream and its consumer below. Scheduled jobs, dead jobs, and stats are
|
|
4
|
+
# managed by Cosmo itself: tune them in Cosmo.configure instead.
|
|
5
|
+
|
|
6
|
+
timeout: 25 # Shutdown timeout in seconds
|
|
7
|
+
concurrency: 1 # Number of worker threads
|
|
8
|
+
max_retries: 3 # Retries for a job class that sets none
|
|
9
|
+
|
|
10
|
+
#http: # Serve GET /health and /ping
|
|
11
|
+
# port: 9090
|
|
12
|
+
# host: 0.0.0.0
|
|
13
|
+
|
|
14
|
+
stream_config: &stream_config
|
|
15
|
+
storage: file # file or memory
|
|
16
|
+
retention: workqueue # workqueue deletes acked messages; limits keeps them until max_age/max_msgs
|
|
17
|
+
duplicate_window: 120 # seconds a Nats-Msg-Id is remembered, so a resent job is stored once
|
|
18
|
+
discard: new # what to drop when the stream is full: new rejects publishes, old evicts the oldest
|
|
19
|
+
allow_direct: true # required by the web UI
|
|
20
|
+
subjects:
|
|
21
|
+
- jobs.%{name}.> # %{name} is the stream name
|
|
22
|
+
|
|
23
|
+
consumer_config: &consumer_config
|
|
24
|
+
ack_policy: explicit # every message is acked on its own
|
|
25
|
+
max_deliver: 30 # deliveries before a message is given up on; keep it above every job class's retry
|
|
26
|
+
max_ack_pending: 20 # unacked messages before the server stops delivering more
|
|
27
|
+
ack_wait: 60 # seconds before an unacked message is redelivered
|
|
28
|
+
subject: jobs.%{name}.> # %{name} is the stream name
|
|
29
|
+
|
|
30
|
+
setup:
|
|
31
|
+
jobs:
|
|
32
|
+
default:
|
|
33
|
+
<<: *stream_config
|
|
34
|
+
description: Default jobs
|
|
35
|
+
# critical:
|
|
36
|
+
# <<: *stream_config
|
|
37
|
+
# description: Critical jobs
|
|
38
|
+
# streams: # Streams for Cosmo::Stream processors
|
|
39
|
+
# events:
|
|
40
|
+
# storage: file
|
|
41
|
+
# retention: limits
|
|
42
|
+
# max_age: 86400
|
|
43
|
+
# subjects:
|
|
44
|
+
# - events.>
|
|
45
|
+
# cron: # Recurring jobs, synced by `cosmo --setup`
|
|
46
|
+
# daily_report:
|
|
47
|
+
# class: ReportJob
|
|
48
|
+
# schedule: "@daily"
|
|
49
|
+
# stream: default
|
|
50
|
+
|
|
51
|
+
consumers:
|
|
52
|
+
jobs:
|
|
53
|
+
default:
|
|
54
|
+
<<: *consumer_config
|
|
55
|
+
priority: 15 # relative polling weight among job streams
|
|
56
|
+
# critical:
|
|
57
|
+
# <<: *consumer_config
|
|
58
|
+
# priority: 50
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
class Config < ::Hash
|
|
5
|
+
# +config.scheduled+ in Cosmo.configure. Turned off, Cosmo neither creates the scheduled stream nor runs the
|
|
6
|
+
# scheduler, and delayed jobs (+perform_in+/+perform_at+) and crons raise {SchedulingDisabledError}.
|
|
7
|
+
Scheduled = Struct.new(:enabled, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
# +config.dead+ in Cosmo.configure: the dead-letter stream's retention (+max_age+ in seconds or a duration such as
|
|
10
|
+
# "7d"). Turned off, a job that gives up is dropped instead of parked.
|
|
11
|
+
Dead = Struct.new(:enabled, :max_age, :max_msgs, :max_bytes, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
# +config.batches+ in Cosmo.configure: how long a batch's tracking data lives (seconds or a duration such as "3d").
|
|
14
|
+
Batches = Struct.new(:expiry, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
# +config.metrics+ in Cosmo.configure: per-day job metrics behind the web UI's Metrics tab, kept for +retention+
|
|
17
|
+
# (seconds or a duration such as "30d"). Turned off, nothing is recorded and the tab is hidden.
|
|
18
|
+
Metrics = Struct.new(:enabled, :retention, keyword_init: true)
|
|
19
|
+
end
|
|
20
|
+
end
|
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,13 +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
|
-
next unless type == :jobs # Every jobs stream supports NATS 2.14 message scheduling.
|
|
51
|
-
|
|
52
|
-
c[:allow_msg_schedules] = true
|
|
53
|
-
cron_subject = "#{API::Cron::Entry::SUBJECT_PREFIX}.#{name}.>"
|
|
54
|
-
c[:subjects] = Array(c[:subjects])
|
|
55
|
-
c[:subjects] << cron_subject unless c[:subjects].include?(cron_subject)
|
|
56
86
|
end
|
|
57
87
|
end
|
|
58
88
|
end
|
|
@@ -80,14 +110,113 @@ module Cosmo
|
|
|
80
110
|
@internal ||= {}
|
|
81
111
|
end
|
|
82
112
|
|
|
83
|
-
|
|
84
|
-
|
|
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)
|
|
85
133
|
end
|
|
86
134
|
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
89
205
|
|
|
90
|
-
|
|
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
|
|
91
220
|
end
|
|
92
221
|
end
|
|
93
222
|
end
|