cosmonats 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +199 -103
- data/lib/cosmo/active_job/adapter.rb +1 -2
- data/lib/cosmo/api/counter.rb +16 -16
- data/lib/cosmo/api/cron.rb +3 -0
- data/lib/cosmo/api/registry.rb +1 -1
- data/lib/cosmo/api/stats/busy.rb +1 -1
- data/lib/cosmo/api/stats/metrics.rb +109 -0
- data/lib/cosmo/api/stats/processes.rb +14 -4
- data/lib/cosmo/api/stats/totals.rb +4 -12
- data/lib/cosmo/api/stats.rb +3 -2
- data/lib/cosmo/batch/counters.rb +19 -0
- data/lib/cosmo/batch.rb +6 -6
- data/lib/cosmo/cli.rb +21 -5
- data/lib/cosmo/client.rb +28 -2
- data/lib/cosmo/config/cosmo.yml +58 -0
- data/lib/cosmo/config/settings.rb +20 -0
- data/lib/cosmo/config.rb +144 -19
- data/lib/cosmo/heartbeat.rb +11 -0
- data/lib/cosmo/job/data.rb +17 -12
- data/lib/cosmo/job/enqueuer.rb +53 -0
- data/lib/cosmo/job/limit.rb +2 -2
- data/lib/cosmo/job/processor.rb +27 -52
- data/lib/cosmo/job/requeue.rb +20 -0
- data/lib/cosmo/job.rb +8 -22
- data/lib/cosmo/logger.rb +15 -2
- data/lib/cosmo/middleware/busy.rb +15 -0
- data/lib/cosmo/middleware/chain.rb +137 -0
- data/lib/cosmo/middleware/limit.rb +50 -0
- data/lib/cosmo/middleware/metrics.rb +39 -0
- data/lib/cosmo/{sentry/job_processor_middleware.rb → middleware/sentry.rb} +26 -30
- data/lib/cosmo/middleware/totals.rb +29 -0
- data/lib/cosmo/middleware.rb +7 -0
- data/lib/cosmo/processor.rb +11 -5
- data/lib/cosmo/publisher.rb +1 -13
- data/lib/cosmo/railtie.rb +2 -2
- data/lib/cosmo/services.rb +69 -0
- data/lib/cosmo/stream/processor.rb +7 -1
- data/lib/cosmo/utils/hash.rb +7 -8
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +58 -0
- data/lib/cosmo/web/chart.rb +98 -0
- data/lib/cosmo/web/controllers/crons.rb +8 -0
- data/lib/cosmo/web/controllers/jobs.rb +16 -2
- data/lib/cosmo/web/controllers/metrics.rb +48 -0
- data/lib/cosmo/web/helpers/application.rb +10 -0
- data/lib/cosmo/web/views/batches/_table.erb +11 -0
- data/lib/cosmo/web/views/jobs/_processes.erb +49 -35
- data/lib/cosmo/web/views/jobs/_stats.erb +4 -0
- data/lib/cosmo/web/views/jobs/_tabs.erb +6 -0
- data/lib/cosmo/web/views/jobs/index.erb +1 -1
- data/lib/cosmo/web/views/layout.erb +1 -1
- data/lib/cosmo/web/views/metrics/_chart.erb +36 -0
- data/lib/cosmo/web/views/metrics/_panel.erb +13 -0
- data/lib/cosmo/web/views/metrics/_table.erb +41 -0
- data/lib/cosmo/web/views/metrics/index.erb +17 -0
- data/lib/cosmo/web.rb +3 -0
- data/lib/cosmo.rb +38 -0
- data/sig/cosmo/api/counter.rbs +2 -3
- data/sig/cosmo/api/stats/busy.rbs +1 -1
- data/sig/cosmo/api/stats/metrics.rbs +42 -0
- data/sig/cosmo/api/stats/processes.rbs +3 -0
- data/sig/cosmo/api/stats/totals.rbs +3 -2
- data/sig/cosmo/batch/counters.rbs +10 -0
- data/sig/cosmo/batch.rbs +3 -4
- data/sig/cosmo/cli.rbs +2 -0
- data/sig/cosmo/client.rbs +3 -1
- data/sig/cosmo/config/settings.rbs +31 -0
- data/sig/cosmo/config.rbs +59 -5
- data/sig/cosmo/heartbeat.rbs +2 -0
- data/sig/cosmo/job/data.rbs +5 -3
- data/sig/cosmo/job/enqueuer.rbs +11 -0
- data/sig/cosmo/job/processor.rbs +3 -5
- data/sig/cosmo/job/requeue.rbs +9 -0
- data/sig/cosmo/job.rbs +0 -2
- data/sig/cosmo/logger.rbs +6 -0
- data/sig/cosmo/middleware/chain.rbs +51 -0
- data/sig/cosmo/middleware/sentry.rbs +14 -0
- data/sig/cosmo/middleware.rbs +35 -0
- data/sig/cosmo/processor.rbs +2 -0
- data/sig/cosmo/publisher.rbs +1 -9
- data/sig/cosmo/services.rbs +18 -0
- data/sig/cosmo/stream/processor.rbs +2 -0
- data/sig/cosmo/utils/hash.rbs +2 -3
- data/sig/cosmo.rbs +11 -0
- metadata +30 -3
- data/lib/cosmo/sentry/auto.rb +0 -6
|
@@ -6,17 +6,27 @@ module Cosmo
|
|
|
6
6
|
# Live worker processes, one entry per process, refreshed by Cosmo::Heartbeat.
|
|
7
7
|
class Processes < Registry
|
|
8
8
|
TTL = 60
|
|
9
|
-
BUCKET = "
|
|
9
|
+
BUCKET = "_cosmoprocesses"
|
|
10
|
+
KINDS = %w[jobs streams jobs+streams none].freeze
|
|
10
11
|
|
|
11
|
-
#
|
|
12
|
-
#
|
|
12
|
+
# Which processors a process is pulling for, judged by its non-empty subscriptions.
|
|
13
|
+
#
|
|
14
|
+
# @param process [Hash]
|
|
15
|
+
# @return [String] one of {KINDS}
|
|
16
|
+
def self.kind(process)
|
|
17
|
+
types = Hash(process[:subscriptions]).reject { |_, names| Array(names).empty? }.keys.map(&:to_s).sort
|
|
18
|
+
types.empty? ? "none" : types.join("+")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Sorted by kind, host and pid before paging: every heartbeat rewrites an entry, so the bucket's own
|
|
22
|
+
# order shifts constantly and would move processes between pages, and each kind stays contiguous.
|
|
13
23
|
#
|
|
14
24
|
# @param page [Integer, nil]
|
|
15
25
|
# @param limit [Integer]
|
|
16
26
|
# @return [Array<Hash>]
|
|
17
27
|
def list(page: nil, limit: LIMIT)
|
|
18
28
|
offset = ([page.to_i, 1].max - 1) * limit
|
|
19
|
-
all.sort_by { [_1[:hostname].to_s, _1[:pid].to_i] }.slice(offset, limit).to_a
|
|
29
|
+
all.sort_by { [KINDS.index(self.class.kind(_1)) || KINDS.size, _1[:hostname].to_s, _1[:pid].to_i] }.slice(offset, limit).to_a
|
|
20
30
|
end
|
|
21
31
|
|
|
22
32
|
# @param identity [String] a unique process id, e.g. +hostname-pid+
|
|
@@ -3,23 +3,15 @@
|
|
|
3
3
|
module Cosmo
|
|
4
4
|
module API
|
|
5
5
|
module Stats
|
|
6
|
-
# Processed and failed job totals across all workers.
|
|
6
|
+
# Processed and failed job totals across all workers, in the +_cosmototals+ counters. They never expire.
|
|
7
7
|
class Totals < Counter
|
|
8
|
+
STREAM_NAME = "_cosmototals"
|
|
9
|
+
DESCRIPTION = "Cosmo statistics"
|
|
10
|
+
|
|
8
11
|
def self.instance
|
|
9
12
|
@instance ||= new("jobs")
|
|
10
13
|
end
|
|
11
14
|
|
|
12
|
-
# Counts the block's outcome: +true+ is processed, +false+ or a raise is failed.
|
|
13
|
-
#
|
|
14
|
-
# @return [void]
|
|
15
|
-
def with
|
|
16
|
-
result = yield
|
|
17
|
-
increment(:processed) if result == true
|
|
18
|
-
increment(:failed) if result == false
|
|
19
|
-
rescue Exception # rubocop:disable Lint/RescueException
|
|
20
|
-
increment(:failed)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
15
|
# @return [Integer]
|
|
24
16
|
def processed
|
|
25
17
|
get(:processed)
|
data/lib/cosmo/api/stats.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "cosmo/api/stats/totals"
|
|
4
4
|
require "cosmo/api/stats/busy"
|
|
5
5
|
require "cosmo/api/stats/processes"
|
|
6
|
+
require "cosmo/api/stats/metrics"
|
|
6
7
|
|
|
7
8
|
module Cosmo
|
|
8
9
|
module API
|
|
@@ -34,11 +35,11 @@ module Cosmo
|
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
def scheduled
|
|
37
|
-
Stream.new("scheduled").size
|
|
38
|
+
Config.scheduled.enabled ? Stream.new("scheduled").size : 0
|
|
38
39
|
end
|
|
39
40
|
|
|
40
41
|
def dead
|
|
41
|
-
Stream.new("dead").size
|
|
42
|
+
Config.dead.enabled ? Stream.new("dead").size : 0
|
|
42
43
|
end
|
|
43
44
|
end
|
|
44
45
|
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cosmo/api/counter"
|
|
4
|
+
|
|
5
|
+
module Cosmo
|
|
6
|
+
class Batch
|
|
7
|
+
# Every batch's total, pending, and failed counters, in the +_cosmobatches+ stream. They are purged once the batch
|
|
8
|
+
# finishes, and expire +config.batches.expiry+ after their last update otherwise, along with the batch's KV state.
|
|
9
|
+
class Counters < API::Counter
|
|
10
|
+
STREAM_NAME = "_cosmobatches"
|
|
11
|
+
DESCRIPTION = "Cosmo batch progress"
|
|
12
|
+
|
|
13
|
+
# @return [Hash]
|
|
14
|
+
def self.stream_config
|
|
15
|
+
super.merge(max_age: Config.to_ns(Utils::Duration.parse(Config.batches.expiry)))
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
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.
|
|
@@ -51,8 +52,8 @@ module Cosmo
|
|
|
51
52
|
# Every batch has an id (bid, a random hex string). State lives
|
|
52
53
|
# in two places, both keyed by that bid:
|
|
53
54
|
#
|
|
54
|
-
# 1. Three atomic counters (via
|
|
55
|
-
# the
|
|
55
|
+
# 1. Three atomic counters (via Batch::Counters, namespace "batch", in the
|
|
56
|
+
# _cosmobatches stream, which expires them with the KV state below):
|
|
56
57
|
#
|
|
57
58
|
# <bid>.total - how many jobs have ever joined this batch
|
|
58
59
|
# <bid>.pending - how many of them are still running
|
|
@@ -83,8 +84,7 @@ module Cosmo
|
|
|
83
84
|
class Batch
|
|
84
85
|
extend Dispatcher
|
|
85
86
|
|
|
86
|
-
BUCKET = "
|
|
87
|
-
DEFAULT_EXPIRY = "3d"
|
|
87
|
+
BUCKET = "_cosmobatches"
|
|
88
88
|
EVENTS = %i[success complete].freeze
|
|
89
89
|
|
|
90
90
|
def self.current
|
|
@@ -100,11 +100,11 @@ module Cosmo
|
|
|
100
100
|
end
|
|
101
101
|
|
|
102
102
|
def self.counter
|
|
103
|
-
@counter ||=
|
|
103
|
+
@counter ||= Counters.new("batch")
|
|
104
104
|
end
|
|
105
105
|
|
|
106
106
|
def self.kv
|
|
107
|
-
@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)
|
|
108
108
|
end
|
|
109
109
|
|
|
110
110
|
attr_reader :bid, :parent_id
|
data/lib/cosmo/cli.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
require "optparse"
|
|
5
|
+
require "fileutils"
|
|
5
6
|
|
|
6
7
|
module Cosmo
|
|
7
8
|
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
@@ -20,6 +21,7 @@ module Cosmo
|
|
|
20
21
|
def run
|
|
21
22
|
@argv = ARGV.dup
|
|
22
23
|
flags, command, options = parse
|
|
24
|
+
return run_init if flags[:init]
|
|
23
25
|
return run_setup(flags) if flags[:setup]
|
|
24
26
|
|
|
25
27
|
load_config(flags)
|
|
@@ -57,10 +59,19 @@ module Cosmo
|
|
|
57
59
|
path = default_path if File.exist?(default_path)
|
|
58
60
|
end
|
|
59
61
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
Config.
|
|
63
|
-
|
|
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)
|
|
64
75
|
end
|
|
65
76
|
|
|
66
77
|
def run_setup(flags)
|
|
@@ -80,7 +91,8 @@ module Cosmo
|
|
|
80
91
|
puts unless first_line
|
|
81
92
|
end
|
|
82
93
|
|
|
83
|
-
|
|
94
|
+
services = Services.setup!
|
|
95
|
+
puts "Service streams are ready: #{services.join(", ")}" if services.any?
|
|
84
96
|
|
|
85
97
|
schedules = Config.dig(:setup, :cron)&.reduce(0) do |sum, (name, entry)|
|
|
86
98
|
class_name = entry.delete(:class)
|
|
@@ -151,6 +163,10 @@ module Cosmo
|
|
|
151
163
|
flags[:setup] = true
|
|
152
164
|
end
|
|
153
165
|
|
|
166
|
+
o.on "--init", "Write #{Config::DEFAULT_PATH} with Cosmo's defaults, then exit" do
|
|
167
|
+
flags[:init] = true
|
|
168
|
+
end
|
|
169
|
+
|
|
154
170
|
o.on_tail "-v", "--version", "Print version" do
|
|
155
171
|
puts "Cosmo #{VERSION}"
|
|
156
172
|
exit(0)
|
data/lib/cosmo/client.rb
CHANGED
|
@@ -144,6 +144,31 @@ module Cosmo
|
|
|
144
144
|
js.get_msg(stream_name, **options)
|
|
145
145
|
end
|
|
146
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
|
+
|
|
147
172
|
def delete_message(name, seq)
|
|
148
173
|
response = nc.request("$JS.API.STREAM.MSG.DELETE.#{name}", JSON.dump({ seq: seq }))
|
|
149
174
|
Utils::Json.parse(response.data, symbolize_names: false)
|
|
@@ -198,7 +223,7 @@ module Cosmo
|
|
|
198
223
|
|
|
199
224
|
# NOTE: KV manager in nats-pure hardcodes the fields it copies into StreamConfig,
|
|
200
225
|
# so `allow_msg_ttl` is never forwarded via create_key_value. Send the raw stream-create API request instead.
|
|
201
|
-
def create_kv_with_msg_ttl(name, **options)
|
|
226
|
+
def create_kv_with_msg_ttl(name, replicas: 1, **options)
|
|
202
227
|
payload = Utils::Json.dump({
|
|
203
228
|
name: "KV_#{name}",
|
|
204
229
|
subjects: ["$KV.#{name}.>"],
|
|
@@ -206,7 +231,8 @@ module Cosmo
|
|
|
206
231
|
allow_direct: true,
|
|
207
232
|
allow_msg_ttl: true,
|
|
208
233
|
allow_rollup_hdrs: true,
|
|
209
|
-
max_msgs_per_subject: 1
|
|
234
|
+
max_msgs_per_subject: 1,
|
|
235
|
+
num_replicas: replicas
|
|
210
236
|
}.merge(options))
|
|
211
237
|
resp = nc.request("$JS.API.STREAM.CREATE.KV_#{name}", payload)
|
|
212
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,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-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
|