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/api/cron.rb
CHANGED
|
@@ -10,41 +10,42 @@ module Cosmo
|
|
|
10
10
|
# Derives the schedule list entirely from NATS.
|
|
11
11
|
# Whatever is deployed in NATS is exactly what appears in the UI.
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
13
|
+
# Every schedule template lives in the +scheduled+ stream, at a subject matching
|
|
14
|
+
# +cosmo.cron.<target stream>.>+. NATS fires a template by publishing its body to
|
|
15
|
+
# +Nats-Schedule-Target+, which has to be a subject the storing stream covers, so a firing
|
|
16
|
+
# lands back in +scheduled+ and the job processor's scheduler dispatches it to the stream
|
|
17
|
+
# that runs it. That confines message scheduling - and the +discard: old+ NATS demands
|
|
18
|
+
# wherever scheduling is enabled - to a single stream.
|
|
17
19
|
class Cron
|
|
20
|
+
STREAM = ::Cosmo::Job::StreamFilter::SCHEDULED.to_s
|
|
21
|
+
|
|
18
22
|
def self.instance
|
|
19
23
|
@instance ||= new
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
# @return [Array<Hash>] every cron schedule currently deployed in NATS
|
|
23
27
|
def all
|
|
24
|
-
|
|
28
|
+
schedules
|
|
25
29
|
rescue StandardError
|
|
26
30
|
[]
|
|
27
31
|
end
|
|
28
32
|
|
|
29
33
|
# Publish (or replace) a schedule message in NATS.
|
|
30
34
|
# @return [Hash, nil] the persisted schedule as a hash, or nil on failure
|
|
35
|
+
# @raise [SchedulingDisabledError] when scheduling is turned off
|
|
31
36
|
def upsert!(class_name: nil, stream: nil, schedule: nil, args: [], timezone: nil, name: nil)
|
|
37
|
+
raise SchedulingDisabledError unless Config.scheduled.enabled
|
|
38
|
+
|
|
32
39
|
e = Entry.new(class_name: class_name, stream: stream, expression: schedule,
|
|
33
40
|
args: args, timezone: timezone, name: name)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"Nats-Schedule-Target" => e.target_subject
|
|
37
|
-
}
|
|
38
|
-
headers["Nats-Schedule-Time-Zone"] = e.timezone if e.timezone
|
|
39
|
-
client.publish(e.schedule_subject, e.job_payload, stream: e.stream, header: headers)
|
|
40
|
-
build_from_nats(e.stream, e.schedule_subject)
|
|
41
|
+
client.publish(e.schedule_subject, e.job_payload, stream: STREAM, header: e.schedule_headers)
|
|
42
|
+
build_from_nats(e.schedule_subject)
|
|
41
43
|
end
|
|
42
44
|
|
|
43
45
|
# Purge the schedule message from NATS (stops future firings).
|
|
44
46
|
# @param subject [String]
|
|
45
47
|
def delete!(subject)
|
|
46
|
-
|
|
47
|
-
client.purge(stream_name, subject)
|
|
48
|
+
client.purge(STREAM, subject)
|
|
48
49
|
rescue NATS::JetStream::Error::NotFound, NATS::IO::Timeout
|
|
49
50
|
nil
|
|
50
51
|
end
|
|
@@ -52,13 +53,12 @@ module Cosmo
|
|
|
52
53
|
# Dispatch the job immediately to the target stream, bypassing the timer.
|
|
53
54
|
# @param schedule_subject [String] e.g. "cosmo.cron.default.report_job.daily"
|
|
54
55
|
def run_now!(schedule_subject) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
55
|
-
|
|
56
|
-
msg = client.get_message(stream_name, subject: schedule_subject)
|
|
56
|
+
msg = client.get_message(STREAM, subject: schedule_subject)
|
|
57
57
|
return unless msg
|
|
58
58
|
|
|
59
59
|
headers = msg.headers || {}
|
|
60
60
|
body = Utils::Json.parse(msg.data) || {}
|
|
61
|
-
target = headers["
|
|
61
|
+
target = headers["X-Subject"]
|
|
62
62
|
return unless target && body[:class]
|
|
63
63
|
|
|
64
64
|
payload = Utils::Json.dump({
|
|
@@ -68,7 +68,7 @@ module Cosmo
|
|
|
68
68
|
retry: body[:retry] || Job::Data.default_retry,
|
|
69
69
|
dead: body[:dead].nil? ? Job::Data::DEFAULTS[:dead] : body[:dead]
|
|
70
70
|
})
|
|
71
|
-
client.publish(target, payload, stream:
|
|
71
|
+
client.publish(target, payload, stream: headers["X-Stream"])
|
|
72
72
|
rescue NATS::JetStream::Error::NotFound
|
|
73
73
|
nil
|
|
74
74
|
end
|
|
@@ -79,14 +79,13 @@ module Cosmo
|
|
|
79
79
|
@client ||= Client.instance
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
def
|
|
83
|
-
|
|
84
|
-
subjects
|
|
85
|
-
subjects.filter_map { |subj| build_from_nats(stream_name, subj) }
|
|
82
|
+
def schedules
|
|
83
|
+
subjects = client.cron_subjects_in_stream(STREAM, "#{Entry::SUBJECT_PREFIX}.>")
|
|
84
|
+
subjects.filter_map { |subj| build_from_nats(subj) }
|
|
86
85
|
end
|
|
87
86
|
|
|
88
|
-
def build_from_nats(
|
|
89
|
-
msg = client.get_message(
|
|
87
|
+
def build_from_nats(subject)
|
|
88
|
+
msg = client.get_message(STREAM, subject: subject)
|
|
90
89
|
return unless msg
|
|
91
90
|
|
|
92
91
|
headers = msg.headers || {}
|
|
@@ -94,13 +93,13 @@ module Cosmo
|
|
|
94
93
|
|
|
95
94
|
{
|
|
96
95
|
class: body[:class],
|
|
97
|
-
stream:
|
|
96
|
+
stream: headers["X-Stream"],
|
|
98
97
|
schedule: headers["Nats-Schedule"],
|
|
99
98
|
timezone: headers["Nats-Schedule-Time-Zone"],
|
|
100
99
|
args: body[:args] || [],
|
|
101
100
|
name: name_from_subject(subject),
|
|
102
101
|
schedule_subject: subject,
|
|
103
|
-
|
|
102
|
+
dispatch_subject: headers["X-Subject"],
|
|
104
103
|
registry_key: subject.split(".").drop(2).join("/")
|
|
105
104
|
}
|
|
106
105
|
rescue StandardError
|
data/lib/cosmo/api/job.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
3
5
|
module Cosmo
|
|
4
6
|
module API
|
|
5
7
|
class Job
|
|
@@ -38,9 +40,37 @@ module Cosmo
|
|
|
38
40
|
@message.subject
|
|
39
41
|
end
|
|
40
42
|
|
|
43
|
+
def error_class
|
|
44
|
+
headers&.dig("X-Error-Class")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def error_message
|
|
48
|
+
headers&.dig("X-Error-Message")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def error_backtrace
|
|
52
|
+
headers&.dig("X-Error-Backtrace")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Array<String>] the backtrace frames, which travel as a single header line
|
|
56
|
+
def error_backtrace_lines
|
|
57
|
+
error_backtrace.to_s.split(::Cosmo::Job::Failure::BACKTRACE_SEPARATOR)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def error?
|
|
61
|
+
!error_class.to_s.empty? || !error_message.to_s.empty?
|
|
62
|
+
end
|
|
63
|
+
|
|
41
64
|
def timestamp
|
|
42
65
|
headers&.dig("Nats-Time-Stamp")
|
|
43
66
|
end
|
|
67
|
+
|
|
68
|
+
# @return [Time, nil] when the message was stored in its stream, parsed from +Nats-Time-Stamp+
|
|
69
|
+
def stored_at
|
|
70
|
+
Time.iso8601(timestamp) if timestamp
|
|
71
|
+
rescue ::ArgumentError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
44
74
|
end
|
|
45
75
|
end
|
|
46
76
|
end
|
data/lib/cosmo/api/kv.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module Cosmo
|
|
4
4
|
module API
|
|
5
5
|
class KV
|
|
6
|
+
LIMIT = 25
|
|
7
|
+
|
|
6
8
|
attr_reader :kv
|
|
7
9
|
|
|
8
10
|
def initialize(name, options = nil)
|
|
@@ -39,17 +41,19 @@ module Cosmo
|
|
|
39
41
|
kv.delete(key)
|
|
40
42
|
end
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
# Walks the bucket and collects live keys. Pass +limit: nil+ to walk the whole
|
|
45
|
+
# bucket -- a bounded +limit+ stops the watch early, so it can never be used
|
|
46
|
+
# to count (see #count).
|
|
47
|
+
def keys(subject = nil, limit: LIMIT, offset: 0)
|
|
48
|
+
walk(subject, limit:, offset:, meta_only: true).map(&:first)
|
|
49
|
+
end
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
# Same walk as #keys, but carries each value back with its key. The watch
|
|
52
|
+
# already has the values in hand, so callers that need them get one pass
|
|
53
|
+
# instead of a #keys walk followed by a #get per key.
|
|
54
|
+
# @return [Array<Array(String, String)>] +[key, value]+ pairs
|
|
55
|
+
def entries(subject = nil, limit: LIMIT, offset: 0)
|
|
56
|
+
walk(subject, limit:, offset:, meta_only: false)
|
|
53
57
|
end
|
|
54
58
|
|
|
55
59
|
# Writes a KV-Operation tombstone (same issue as #delete on ttl buckets).
|
|
@@ -70,7 +74,7 @@ module Cosmo
|
|
|
70
74
|
end
|
|
71
75
|
|
|
72
76
|
def count
|
|
73
|
-
keys.size
|
|
77
|
+
keys(limit: nil).size
|
|
74
78
|
rescue NATS::KeyValue::NoKeysFoundError, NATS::JetStream::Error::NotFound
|
|
75
79
|
0
|
|
76
80
|
end
|
|
@@ -78,6 +82,22 @@ module Cosmo
|
|
|
78
82
|
|
|
79
83
|
private
|
|
80
84
|
|
|
85
|
+
def walk(subject, limit:, offset:, meta_only:)
|
|
86
|
+
results = []
|
|
87
|
+
skipped = 0
|
|
88
|
+
watcher = kv.watch(subject || ">", ignore_deletes: true, meta_only:)
|
|
89
|
+
watcher.each do |entry|
|
|
90
|
+
break unless entry
|
|
91
|
+
|
|
92
|
+
next skipped += 1 if skipped < offset.to_i
|
|
93
|
+
|
|
94
|
+
results << [entry.key, entry.value]
|
|
95
|
+
break if limit && results.size >= limit
|
|
96
|
+
end
|
|
97
|
+
watcher.stop
|
|
98
|
+
results
|
|
99
|
+
end
|
|
100
|
+
|
|
81
101
|
# CAS = Compare-And-Swap: publish +value+ with a per-message Nats-TTL,
|
|
82
102
|
# but only if the subject's current last sequence matches +last_seq+
|
|
83
103
|
# (sent as the Nats-Expected-Last-Subject-Sequence header). Raises
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
module API
|
|
5
|
+
# Base for a KV bucket of JSON entries that live only while their writer keeps refreshing them:
|
|
6
|
+
# a writer re-puts its entries faster than the bucket's TTL, and an entry whose writer is gone expires.
|
|
7
|
+
# Subclasses define +BUCKET+ and +TTL+.
|
|
8
|
+
class Registry
|
|
9
|
+
LIMIT = 25
|
|
10
|
+
|
|
11
|
+
def self.instance
|
|
12
|
+
@instance ||= new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@kv = bucket
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Writes or refreshes an entry, recreating the bucket if it was deleted under a running writer.
|
|
20
|
+
#
|
|
21
|
+
# @param key [String, Integer]
|
|
22
|
+
# @param value [Hash]
|
|
23
|
+
# @return [void]
|
|
24
|
+
def put(key, value)
|
|
25
|
+
@kv.set(sanitize(key), Utils::Json.dump(value))
|
|
26
|
+
rescue NATS::JetStream::Error::NoStreamResponse
|
|
27
|
+
@kv = bucket
|
|
28
|
+
@kv.set(sanitize(key), Utils::Json.dump(value))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Removes an entry, leaving no tombstone behind.
|
|
32
|
+
#
|
|
33
|
+
# @param key [String, Integer]
|
|
34
|
+
# @return [void]
|
|
35
|
+
def remove(key)
|
|
36
|
+
@kv.erase(sanitize(key))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param page [Integer, nil]
|
|
40
|
+
# @param limit [Integer]
|
|
41
|
+
# @return [Array<Hash>]
|
|
42
|
+
def list(page: nil, limit: LIMIT)
|
|
43
|
+
offset = ([page.to_i, 1].max - 1) * limit
|
|
44
|
+
@kv.entries(limit:, offset:).filter_map { Utils::Json.parse(_1.last) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Array<Hash>] every live entry
|
|
48
|
+
def all
|
|
49
|
+
@kv.entries(limit: nil).filter_map { Utils::Json.parse(_1.last) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Integer] the number of live entries
|
|
53
|
+
def size
|
|
54
|
+
@kv.size
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def bucket
|
|
60
|
+
KV.new(self.class::BUCKET, { ttl: self.class::TTL, replicas: Config.replicas })
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def sanitize(key)
|
|
64
|
+
key.to_s.gsub(/[^\w.=-]/, "_")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Cosmo
|
|
6
|
+
module API
|
|
7
|
+
module Stats
|
|
8
|
+
# Jobs being executed right now, one entry per in-flight message, across all workers.
|
|
9
|
+
class Busy < Registry
|
|
10
|
+
TTL = 70
|
|
11
|
+
HEARTBEAT = 30
|
|
12
|
+
BUCKET = "_cosmobusy"
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
super
|
|
16
|
+
@messages = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def with(message)
|
|
20
|
+
add(message)
|
|
21
|
+
yield
|
|
22
|
+
ensure
|
|
23
|
+
delete(message)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def add(message)
|
|
27
|
+
@thread ||= Thread.new { heartbeat_loop }
|
|
28
|
+
meta = message.metadata
|
|
29
|
+
seq = meta.sequence.stream
|
|
30
|
+
entry = { data: message.data, stream: meta.stream, worker: worker_id,
|
|
31
|
+
started_at: Time.now.to_i, delivery: meta.num_delivered }
|
|
32
|
+
@messages[seq] = entry
|
|
33
|
+
put(seq, entry)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def delete(message)
|
|
37
|
+
seq = message.metadata.sequence.stream
|
|
38
|
+
@messages.delete(seq)
|
|
39
|
+
remove(seq)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def list(...)
|
|
43
|
+
super.map { _1.merge(data: Utils::Json.parse(_1[:data])) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def heartbeat_loop
|
|
49
|
+
loop do
|
|
50
|
+
sleep(HEARTBEAT)
|
|
51
|
+
@messages.dup.each { |seq, entry| put(seq, entry) rescue StandardError }
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
Logger.debug "Busy heartbeat error: #{e.class} #{e.message}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def worker_id
|
|
58
|
+
@worker_id ||= "#{Socket.gethostname}-#{::Process.pid}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -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]
|
|
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, days: 30)
|
|
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 == 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
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
module API
|
|
5
|
+
module Stats
|
|
6
|
+
# Live worker processes, one entry per process, refreshed by Cosmo::Heartbeat.
|
|
7
|
+
class Processes < Registry
|
|
8
|
+
TTL = 60
|
|
9
|
+
BUCKET = "_cosmoprocesses"
|
|
10
|
+
KINDS = %w[jobs streams jobs+streams none].freeze
|
|
11
|
+
|
|
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.
|
|
23
|
+
#
|
|
24
|
+
# @param page [Integer, nil]
|
|
25
|
+
# @param limit [Integer]
|
|
26
|
+
# @return [Array<Hash>]
|
|
27
|
+
def list(page: nil, limit: LIMIT)
|
|
28
|
+
offset = ([page.to_i, 1].max - 1) * limit
|
|
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
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param identity [String] a unique process id, e.g. +hostname-pid+
|
|
33
|
+
# @param info [Hash] the process details shown in the Web UI
|
|
34
|
+
# @return [void]
|
|
35
|
+
def register(identity, info)
|
|
36
|
+
put(identity, info)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param identity [String]
|
|
40
|
+
# @return [void]
|
|
41
|
+
def unregister(identity)
|
|
42
|
+
remove(identity)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
module API
|
|
5
|
+
module Stats
|
|
6
|
+
# Processed and failed job totals across all workers, in the +_cosmototals+ counters. They never expire.
|
|
7
|
+
class Totals < Counter
|
|
8
|
+
STREAM_NAME = "_cosmototals"
|
|
9
|
+
DESCRIPTION = "Cosmo statistics"
|
|
10
|
+
|
|
11
|
+
def self.instance
|
|
12
|
+
@instance ||= new("jobs")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @return [Integer]
|
|
16
|
+
def processed
|
|
17
|
+
get(:processed)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Integer]
|
|
21
|
+
def failed
|
|
22
|
+
get(:failed)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/cosmo/api/stats.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "cosmo/api/
|
|
4
|
-
require "cosmo/api/busy"
|
|
3
|
+
require "cosmo/api/stats/totals"
|
|
4
|
+
require "cosmo/api/stats/busy"
|
|
5
|
+
require "cosmo/api/stats/processes"
|
|
6
|
+
require "cosmo/api/stats/metrics"
|
|
5
7
|
|
|
6
8
|
module Cosmo
|
|
7
9
|
module API
|
|
@@ -13,11 +15,11 @@ module Cosmo
|
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def processed
|
|
16
|
-
|
|
18
|
+
Totals.instance.processed
|
|
17
19
|
end
|
|
18
20
|
|
|
19
21
|
def failed
|
|
20
|
-
|
|
22
|
+
Totals.instance.failed
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
def busy
|
|
@@ -33,11 +35,11 @@ module Cosmo
|
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
def scheduled
|
|
36
|
-
Stream.new("scheduled").size
|
|
38
|
+
Config.scheduled.enabled ? Stream.new("scheduled").size : 0
|
|
37
39
|
end
|
|
38
40
|
|
|
39
41
|
def dead
|
|
40
|
-
Stream.new("dead").size
|
|
42
|
+
Config.dead.enabled ? Stream.new("dead").size : 0
|
|
41
43
|
end
|
|
42
44
|
end
|
|
43
45
|
end
|
data/lib/cosmo/api/stream.rb
CHANGED
|
@@ -36,7 +36,7 @@ module Cosmo
|
|
|
36
36
|
|
|
37
37
|
def total
|
|
38
38
|
all_msgs = info[:state].messages.to_i
|
|
39
|
-
cron_count = Client.instance.cron_subjects_in_stream(name, "#{Cron::Entry::SUBJECT_PREFIX}
|
|
39
|
+
cron_count = Client.instance.cron_subjects_in_stream(name, "#{Cron::Entry::SUBJECT_PREFIX}.>").size
|
|
40
40
|
[all_msgs - cron_count, 0].max
|
|
41
41
|
rescue NATS::Error
|
|
42
42
|
0
|
data/lib/cosmo/api.rb
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "cosmo/api/stream"
|
|
4
|
-
require "cosmo/api/counter"
|
|
5
3
|
require "cosmo/api/kv"
|
|
4
|
+
require "cosmo/api/counter"
|
|
5
|
+
require "cosmo/api/registry"
|
|
6
|
+
require "cosmo/api/stream"
|
|
6
7
|
require "cosmo/api/stats"
|
|
7
8
|
require "cosmo/api/cron"
|
|
8
9
|
require "cosmo/api/batch"
|
|
@@ -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
|