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/api/stats/busy.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Cosmo
|
|
|
9
9
|
class Busy < Registry
|
|
10
10
|
TTL = 70
|
|
11
11
|
HEARTBEAT = 30
|
|
12
|
-
BUCKET = "
|
|
12
|
+
BUCKET = "_cosmobusy"
|
|
13
13
|
|
|
14
14
|
def initialize
|
|
15
15
|
super
|
|
@@ -43,6 +43,11 @@ module Cosmo
|
|
|
43
43
|
super.map { _1.merge(data: Utils::Json.parse(_1[:data])) }
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
# @return [Hash{String => Integer}] the number of jobs running per stream, for the streams running any
|
|
47
|
+
def by_stream
|
|
48
|
+
all.group_by { _1[:stream] }.transform_values(&:size)
|
|
49
|
+
end
|
|
50
|
+
|
|
46
51
|
private
|
|
47
52
|
|
|
48
53
|
def heartbeat_loop
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
module API
|
|
5
|
+
module Stats
|
|
6
|
+
# Per-day metrics for every job class: runs, failures, and execution and wait time, kept in their own counters
|
|
7
|
+
# stream for +config.metrics.retention+. Workers {#record} executions in memory and the heartbeat {#flush}es them,
|
|
8
|
+
# so a job costs no NATS round trip; a crashed worker loses at most one heartbeat's worth.
|
|
9
|
+
class Metrics < Counter
|
|
10
|
+
STREAM_NAME = "_cosmometrics"
|
|
11
|
+
DESCRIPTION = "Cosmo metrics"
|
|
12
|
+
DAY = 86_400
|
|
13
|
+
|
|
14
|
+
def self.instance
|
|
15
|
+
@instance ||= new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Each day's counters expire +config.metrics.retention+ after their last update.
|
|
19
|
+
#
|
|
20
|
+
# @return [Hash]
|
|
21
|
+
def self.stream_config
|
|
22
|
+
super.merge(max_age: Config.to_ns(Utils::Duration.parse(Config.metrics.retention)))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize
|
|
26
|
+
super("jobs")
|
|
27
|
+
@lock = Mutex.new
|
|
28
|
+
@buffer = buffer
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Adds one execution to the buffer {#flush} writes.
|
|
32
|
+
#
|
|
33
|
+
# @param job_class [String]
|
|
34
|
+
# @param exec_ms [Numeric, nil] how long a successful execution took
|
|
35
|
+
# @param wait_ms [Numeric, nil] time from enqueue to the job's first delivery
|
|
36
|
+
# @param failed [Boolean]
|
|
37
|
+
# @return [void]
|
|
38
|
+
def record(job_class, exec_ms: nil, wait_ms: nil, failed: false)
|
|
39
|
+
key = [Time.now.utc.strftime("%Y%m%d"), job_class.gsub("::", "-")]
|
|
40
|
+
@lock.synchronize do
|
|
41
|
+
totals = @buffer[key]
|
|
42
|
+
failed ? totals[:failed] += 1 : totals[:count] += 1
|
|
43
|
+
totals[:exec_ms] += exec_ms if exec_ms
|
|
44
|
+
next unless wait_ms
|
|
45
|
+
|
|
46
|
+
totals[:waited] += 1
|
|
47
|
+
totals[:wait_ms] += wait_ms
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Writes the buffered executions to the counters and empties the buffer.
|
|
52
|
+
#
|
|
53
|
+
# @return [void]
|
|
54
|
+
def flush
|
|
55
|
+
pending = @lock.synchronize { @buffer.tap { @buffer = buffer } }
|
|
56
|
+
pending.each do |(day, job), totals|
|
|
57
|
+
totals.each { |field, value| increment("#{day}.#{job}.#{field}", by: value.round) if value.positive? }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param days [Integer] today and the days before it
|
|
62
|
+
# @return [Array<Hash>] per job class +:job+, +:count+, +:failed+, +:exec_ms+ and +:wait_ms+ (averages in
|
|
63
|
+
# milliseconds, nil without data), busiest first
|
|
64
|
+
def summary(days: 1)
|
|
65
|
+
totals = Hash.new { |hash, job| hash[job] = Hash.new(0) }
|
|
66
|
+
read(days).each { |(_, job, field), value| totals[job][field] += value }
|
|
67
|
+
totals.map { |job, sums| row(sums).merge(job: job) }.sort_by { -_1[:count] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @param job_class [String, nil] nil sums every job class
|
|
71
|
+
# @param days [Integer]
|
|
72
|
+
# @return [Array<Hash>] one +{ date:, count:, failed:, exec_ms:, wait_ms: }+ per day, oldest first
|
|
73
|
+
def daily(job_class = nil, days: 30) # rubocop:disable Metrics/AbcSize
|
|
74
|
+
sums = Hash.new { |hash, day| hash[day] = Hash.new(0) }
|
|
75
|
+
read(days).each { |(day, job, field), value| sums[day][field] += value if job_class.nil? || job == job_class }
|
|
76
|
+
dates(days).map { |day| row(sums[day]).merge(date: day) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def buffer
|
|
82
|
+
Hash.new { |hash, key| hash[key] = Hash.new(0) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def read(days)
|
|
86
|
+
filters = dates(days).map { "#{STREAM_NAME}.#{@namespace}.#{_1}.>" }
|
|
87
|
+
client.last_messages(STREAM_NAME, filters).map do |subject, data|
|
|
88
|
+
day, job, field = subject.split(".").last(3)
|
|
89
|
+
[[day, job.gsub("-", "::"), field.to_sym], Utils::Json.parse(data, default: {})[:val].to_i]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def dates(days)
|
|
94
|
+
now = Time.now.utc
|
|
95
|
+
(days - 1).downto(0).map { (now - (_1 * DAY)).strftime("%Y%m%d") }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def row(sums)
|
|
99
|
+
{ count: sums[:count], failed: sums[:failed], exec_ms: average(sums[:exec_ms], sums[:count]),
|
|
100
|
+
wait_ms: average(sums[:wait_ms], sums[:waited]) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def average(total, count)
|
|
104
|
+
total.fdiv(count).round(1) if count.positive?
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -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,26 @@ 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
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Array<Hash>] a {.queue} row per job stream
|
|
46
|
+
def queues
|
|
47
|
+
busy = Busy.instance.by_stream
|
|
48
|
+
Stream.jobs.map { queue(_1, busy) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param stream [Stream] a job stream
|
|
52
|
+
# @param busy [Hash{String => Integer}] running jobs per stream, as {Busy#by_stream} counts them
|
|
53
|
+
# @return [Hash] the stream's +:name+, waiting messages as +:size+, running jobs as +:busy+, redeliveries as
|
|
54
|
+
# +:retries+, and whether it is +:paused+
|
|
55
|
+
def queue(stream, busy = Busy.instance.by_stream)
|
|
56
|
+
{ name: stream.name, size: stream.size, busy: busy.fetch(stream.name, 0), retries: stream.retries,
|
|
57
|
+
paused: stream.paused? }
|
|
42
58
|
end
|
|
43
59
|
end
|
|
44
60
|
end
|
data/lib/cosmo/api/stream.rb
CHANGED
|
@@ -44,9 +44,14 @@ module Cosmo
|
|
|
44
44
|
alias size total
|
|
45
45
|
|
|
46
46
|
def retries
|
|
47
|
-
|
|
47
|
+
consumers.sum { _1["num_redelivered"].to_i }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Array<Hash>] the stream's consumers as JetStream reports them, with their config and delivery state
|
|
51
|
+
def consumers
|
|
52
|
+
client.list_consumers(name)
|
|
48
53
|
rescue NATS::Error
|
|
49
|
-
|
|
54
|
+
[]
|
|
50
55
|
end
|
|
51
56
|
|
|
52
57
|
def each
|
|
@@ -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
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require "socket"
|
|
4
4
|
require "nats/client"
|
|
5
|
-
require "cosmo/utils/overrides"
|
|
6
5
|
|
|
7
6
|
module Cosmo
|
|
8
7
|
class Client
|
|
@@ -10,6 +9,9 @@ module Cosmo
|
|
|
10
9
|
JS_TIMEOUT = 5
|
|
11
10
|
# Seconds to wait for the TCP connect+handshake. Override with COSMO_CONNECT_TIMEOUT.
|
|
12
11
|
CONNECT_TIMEOUT = 2
|
|
12
|
+
# Reconnect attempts before the connection closes for good; -1 never gives up, so a process outlives a NATS
|
|
13
|
+
# outage. nats-pure lets NATS_MAX_RECONNECT_ATTEMPTS override it.
|
|
14
|
+
MAX_RECONNECT_ATTEMPTS = -1
|
|
13
15
|
|
|
14
16
|
def self.instance
|
|
15
17
|
@instance ||= Client.new
|
|
@@ -41,7 +43,8 @@ module Cosmo
|
|
|
41
43
|
def initialize(nats_url: ENV.fetch("NATS_URL", "nats://localhost:4222"), name: self.class.default_name)
|
|
42
44
|
@name = name
|
|
43
45
|
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
|
|
46
|
+
@nc = NATS.connect(nats_url, name: @name, connect_timeout: self.class.connect_timeout,
|
|
47
|
+
max_reconnect_attempts: MAX_RECONNECT_ATTEMPTS)
|
|
45
48
|
Logger.debug "Connection established"
|
|
46
49
|
@js = @nc.jetstream(timeout: self.class.js_timeout)
|
|
47
50
|
end
|
|
@@ -144,6 +147,31 @@ module Cosmo
|
|
|
144
147
|
js.get_msg(stream_name, **options)
|
|
145
148
|
end
|
|
146
149
|
|
|
150
|
+
# The last message on every subject matching +filters+, in one batched direct get (NATS ≥ 2.11). Needs
|
|
151
|
+
# +allow_direct+ on the stream.
|
|
152
|
+
#
|
|
153
|
+
# @param stream_name [String]
|
|
154
|
+
# @param filters [Array<String>] subject filters, wildcards allowed
|
|
155
|
+
# @return [Hash{String => String}] message data by subject
|
|
156
|
+
def last_messages(stream_name, filters) # rubocop:disable Metrics/AbcSize
|
|
157
|
+
inbox = nc.new_inbox
|
|
158
|
+
sub = nc.subscribe(inbox)
|
|
159
|
+
nc.publish("$JS.API.DIRECT.GET.#{stream_name}", Utils::Json.dump({ multi_last: filters, batch: 10_000 }), inbox)
|
|
160
|
+
messages = {}
|
|
161
|
+
loop do
|
|
162
|
+
msg = sub.next_msg(timeout: self.class.js_timeout)
|
|
163
|
+
subject = msg.header&.dig(NATS::JetStream::Header::SUBJECT)
|
|
164
|
+
break unless subject
|
|
165
|
+
|
|
166
|
+
messages[subject] = msg.data
|
|
167
|
+
end
|
|
168
|
+
messages
|
|
169
|
+
rescue NATS::Timeout
|
|
170
|
+
messages
|
|
171
|
+
ensure
|
|
172
|
+
sub&.unsubscribe
|
|
173
|
+
end
|
|
174
|
+
|
|
147
175
|
def delete_message(name, seq)
|
|
148
176
|
response = nc.request("$JS.API.STREAM.MSG.DELETE.#{name}", JSON.dump({ seq: seq }))
|
|
149
177
|
Utils::Json.parse(response.data, symbolize_names: false)
|
|
@@ -170,8 +198,9 @@ module Cosmo
|
|
|
170
198
|
|
|
171
199
|
private
|
|
172
200
|
|
|
173
|
-
# NOTE: nats-pure's #pull_subscribe
|
|
174
|
-
#
|
|
201
|
+
# NOTE: nats-pure's #pull_subscribe can't create a true ephemeral pull consumer: its rescue branch
|
|
202
|
+
# unconditionally sets `config[:durable_name] = durable` (jetstream.rb). So the consumer is created
|
|
203
|
+
# here, and #pull_subscribe only binds to it by name.
|
|
175
204
|
def ephemeral_subscribe(subject, config) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
176
205
|
config = config.dup
|
|
177
206
|
stream = config.delete(:stream) or raise ArgumentError, "stream required for ephemeral consumers"
|
|
@@ -186,19 +215,12 @@ module Cosmo
|
|
|
186
215
|
end
|
|
187
216
|
|
|
188
217
|
info = js.add_consumer(stream, consumer_config)
|
|
189
|
-
|
|
190
|
-
sub = nc.subscribe(nc.new_inbox)
|
|
191
|
-
sub.extend(NATS::JetStream.const_get(:PullSubscription))
|
|
192
|
-
sub.jsi = NATS::JetStream.const_get(:JS)::Sub.new(
|
|
193
|
-
js: js, stream: stream, consumer: info.name,
|
|
194
|
-
nms: "#{js.prefix}.CONSUMER.MSG.NEXT.#{stream}.#{info.name}"
|
|
195
|
-
)
|
|
196
|
-
sub
|
|
218
|
+
js.pull_subscribe(subject, nil, stream: stream, name: info.name)
|
|
197
219
|
end
|
|
198
220
|
|
|
199
221
|
# NOTE: KV manager in nats-pure hardcodes the fields it copies into StreamConfig,
|
|
200
222
|
# 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)
|
|
223
|
+
def create_kv_with_msg_ttl(name, replicas: 1, **options)
|
|
202
224
|
payload = Utils::Json.dump({
|
|
203
225
|
name: "KV_#{name}",
|
|
204
226
|
subjects: ["$KV.#{name}.>"],
|
|
@@ -206,7 +228,8 @@ module Cosmo
|
|
|
206
228
|
allow_direct: true,
|
|
207
229
|
allow_msg_ttl: true,
|
|
208
230
|
allow_rollup_hdrs: true,
|
|
209
|
-
max_msgs_per_subject: 1
|
|
231
|
+
max_msgs_per_subject: 1,
|
|
232
|
+
num_replicas: replicas
|
|
210
233
|
}.merge(options))
|
|
211
234
|
resp = nc.request("$JS.API.STREAM.CREATE.KV_#{name}", payload)
|
|
212
235
|
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
|