cosmonats 0.6.0 → 0.7.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 +101 -17
- data/lib/cosmo/api/counter.rb +14 -11
- data/lib/cosmo/api/cron/entry.rb +23 -3
- data/lib/cosmo/api/cron.rb +22 -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/processes.rb +37 -0
- data/lib/cosmo/api/stats/totals.rb +35 -0
- data/lib/cosmo/api/stats.rb +5 -4
- data/lib/cosmo/api/stream.rb +1 -1
- data/lib/cosmo/api.rb +3 -2
- data/lib/cosmo/batch.rb +9 -2
- data/lib/cosmo/cli.rb +27 -7
- data/lib/cosmo/client.rb +33 -5
- data/lib/cosmo/config.rb +6 -2
- data/lib/cosmo/engine.rb +103 -4
- data/lib/cosmo/heartbeat.rb +129 -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/failure.rb +52 -0
- data/lib/cosmo/job/processor.rb +87 -25
- data/lib/cosmo/job/stream_filter.rb +54 -0
- data/lib/cosmo/job.rb +2 -0
- data/lib/cosmo/processor.rb +39 -4
- data/lib/cosmo/sentry/job_processor_middleware.rb +1 -2
- data/lib/cosmo/stream/message.rb +2 -1
- data/lib/cosmo/stream/processor.rb +5 -0
- 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 +21 -0
- data/lib/cosmo/web/controllers/jobs.rb +28 -4
- data/lib/cosmo/web/helpers/application.rb +10 -1
- 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 +131 -0
- data/lib/cosmo/web/views/jobs/_scheduled.erb +1 -1
- 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/streams/index.erb +1 -1
- data/lib/cosmo/web.rb +2 -0
- data/lib/cosmo.rb +13 -0
- data/sig/cosmo/api/counter.rbs +2 -4
- 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/processes.rbs +16 -0
- data/sig/cosmo/api/stats/totals.rbs +17 -0
- data/sig/cosmo/cli.rbs +4 -0
- data/sig/cosmo/client.rbs +12 -1
- data/sig/cosmo/engine.rbs +29 -0
- data/sig/cosmo/heartbeat.rbs +36 -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/failure.rbs +28 -0
- data/sig/cosmo/job/processor.rbs +17 -1
- data/sig/cosmo/job/stream_filter.rbs +25 -0
- data/sig/cosmo/processor.rbs +12 -1
- data/sig/cosmo/stream/message.rbs +3 -1
- data/sig/cosmo/stream/processor.rbs +2 -0
- data/sig/cosmo/utils/thread_pool.rbs +3 -0
- data/sig/cosmo.rbs +4 -0
- metadata +33 -20
- data/lib/cosmo/api/busy.rb +0 -66
- data/sig/cosmo/api/busy.rbs +0 -35
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 })
|
|
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 = "cosmo_jobs_busy"
|
|
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,37 @@
|
|
|
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 = "cosmo_processes"
|
|
10
|
+
|
|
11
|
+
# Sorted by host and pid before paging: every heartbeat rewrites an entry, so the bucket's own
|
|
12
|
+
# order shifts constantly and would move processes between pages.
|
|
13
|
+
#
|
|
14
|
+
# @param page [Integer, nil]
|
|
15
|
+
# @param limit [Integer]
|
|
16
|
+
# @return [Array<Hash>]
|
|
17
|
+
def list(page: nil, limit: LIMIT)
|
|
18
|
+
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
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param identity [String] a unique process id, e.g. +hostname-pid+
|
|
23
|
+
# @param info [Hash] the process details shown in the Web UI
|
|
24
|
+
# @return [void]
|
|
25
|
+
def register(identity, info)
|
|
26
|
+
put(identity, info)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param identity [String]
|
|
30
|
+
# @return [void]
|
|
31
|
+
def unregister(identity)
|
|
32
|
+
remove(identity)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
module API
|
|
5
|
+
module Stats
|
|
6
|
+
# Processed and failed job totals across all workers.
|
|
7
|
+
class Totals < Counter
|
|
8
|
+
def self.instance
|
|
9
|
+
@instance ||= new("jobs")
|
|
10
|
+
end
|
|
11
|
+
|
|
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
|
+
# @return [Integer]
|
|
24
|
+
def processed
|
|
25
|
+
get(:processed)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Integer]
|
|
29
|
+
def failed
|
|
30
|
+
get(:failed)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/cosmo/api/stats.rb
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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"
|
|
5
6
|
|
|
6
7
|
module Cosmo
|
|
7
8
|
module API
|
|
@@ -13,11 +14,11 @@ module Cosmo
|
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def processed
|
|
16
|
-
|
|
17
|
+
Totals.instance.processed
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def failed
|
|
20
|
-
|
|
21
|
+
Totals.instance.failed
|
|
21
22
|
end
|
|
22
23
|
|
|
23
24
|
def busy
|
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"
|
data/lib/cosmo/batch.rb
CHANGED
|
@@ -39,6 +39,13 @@ module Cosmo
|
|
|
39
39
|
# when everything succeeds. Always call #jobs at least once, even with nothing
|
|
40
40
|
# in it - a batch that never gets a #jobs call never closes and never fires.
|
|
41
41
|
#
|
|
42
|
+
# You can pass your own bid instead of letting one be generated - useful
|
|
43
|
+
# when something else (e.g. an ActiveRecord id) needs to match it:
|
|
44
|
+
#
|
|
45
|
+
# Cosmo::Batch.new(bid: your_id)
|
|
46
|
+
#
|
|
47
|
+
# Same meaning as a plain #new either way - it's still a fresh batch.
|
|
48
|
+
#
|
|
42
49
|
# == How this is actually stored in NATS
|
|
43
50
|
#
|
|
44
51
|
# Every batch has an id (bid, a random hex string). State lives
|
|
@@ -102,8 +109,8 @@ module Cosmo
|
|
|
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
|
@@ -4,7 +4,7 @@ require "yaml"
|
|
|
4
4
|
require "optparse"
|
|
5
5
|
|
|
6
6
|
module Cosmo
|
|
7
|
-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
7
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
8
8
|
class CLI
|
|
9
9
|
def self.run
|
|
10
10
|
instance.run
|
|
@@ -14,15 +14,22 @@ module Cosmo
|
|
|
14
14
|
@instance ||= new
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# @return [Array<String>, nil] the arguments the process was started with, before parsing consumed them
|
|
18
|
+
attr_reader :argv
|
|
19
|
+
|
|
17
20
|
def run
|
|
21
|
+
@argv = ARGV.dup
|
|
18
22
|
flags, command, options = parse
|
|
19
23
|
return run_setup(flags) if flags[:setup]
|
|
20
24
|
|
|
21
25
|
load_config(flags)
|
|
26
|
+
Engine.processors_for(command).each { _1.validate_options!(options) }
|
|
22
27
|
puts self.class.banner
|
|
23
28
|
boot_application
|
|
24
29
|
require_path(flags[:require])
|
|
25
30
|
Engine.run(command, options)
|
|
31
|
+
rescue Error => e
|
|
32
|
+
abort e.message
|
|
26
33
|
end
|
|
27
34
|
|
|
28
35
|
private
|
|
@@ -53,6 +60,7 @@ module Cosmo
|
|
|
53
60
|
Config.load(path)
|
|
54
61
|
Config.set(:concurrency, flags[:concurrency]) if flags[:concurrency]
|
|
55
62
|
Config.set(:timeout, flags[:timeout]) if flags[:timeout]
|
|
63
|
+
Config.set(:http, :port, flags[:http_port]) if flags[:http_port]
|
|
56
64
|
end
|
|
57
65
|
|
|
58
66
|
def run_setup(flags)
|
|
@@ -69,9 +77,11 @@ module Cosmo
|
|
|
69
77
|
first_line ? print("Stream is ready: #{name}") : print(", #{name}")
|
|
70
78
|
first_line = false
|
|
71
79
|
end
|
|
80
|
+
puts unless first_line
|
|
72
81
|
end
|
|
73
82
|
|
|
74
|
-
|
|
83
|
+
API::Counter.setup!
|
|
84
|
+
|
|
75
85
|
schedules = Config.dig(:setup, :cron)&.reduce(0) do |sum, (name, entry)|
|
|
76
86
|
class_name = entry.delete(:class)
|
|
77
87
|
API::Cron.instance.upsert!(**entry, name: name, class_name: class_name)
|
|
@@ -129,6 +139,10 @@ module Cosmo
|
|
|
129
139
|
flags[:timeout] = arg
|
|
130
140
|
end
|
|
131
141
|
|
|
142
|
+
o.on "-p", "--http-port INT", Integer, "Start HTTP server with /health endpoint on port" do |arg|
|
|
143
|
+
flags[:http_port] = arg
|
|
144
|
+
end
|
|
145
|
+
|
|
132
146
|
o.on "-C", "--config PATH", "Path to config file" do |arg|
|
|
133
147
|
flags[:config_file] = arg
|
|
134
148
|
end
|
|
@@ -155,8 +169,16 @@ module Cosmo
|
|
|
155
169
|
OptionParser.new do |o|
|
|
156
170
|
o.banner = "Usage: cosmo jobs [options]"
|
|
157
171
|
|
|
158
|
-
o.on "--
|
|
159
|
-
options[:
|
|
172
|
+
o.on "--streams NAMES", "Only subscribe to these job streams (comma-separated), instead of all configured streams" do |arg|
|
|
173
|
+
options[:streams] = Array(options[:streams]) | arg.split(",")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
o.on "--stream NAME", "Same as --streams, for a single job stream" do |arg|
|
|
177
|
+
options[:streams] = Array(options[:streams]) | [arg]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
o.on "--[no-]scheduler", "Dispatch scheduled jobs (default: yes)" do |arg|
|
|
181
|
+
options[:scheduler] = arg
|
|
160
182
|
end
|
|
161
183
|
|
|
162
184
|
o.on "--subject NAME", "Job's subject" do |arg|
|
|
@@ -214,7 +236,7 @@ module Cosmo
|
|
|
214
236
|
require path
|
|
215
237
|
end
|
|
216
238
|
|
|
217
|
-
# rubocop:disable Layout/TrailingWhitespace,Lint/IneffectiveAccessModifier
|
|
239
|
+
# rubocop:disable-next Layout/TrailingWhitespace,Lint/IneffectiveAccessModifier
|
|
218
240
|
def self.banner
|
|
219
241
|
<<-TEXT
|
|
220
242
|
.#%+:
|
|
@@ -254,7 +276,5 @@ module Cosmo
|
|
|
254
276
|
:.
|
|
255
277
|
TEXT
|
|
256
278
|
end
|
|
257
|
-
# rubocop:enable Layout/TrailingWhitespace,Lint/IneffectiveAccessModifier
|
|
258
279
|
end
|
|
259
|
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
260
280
|
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)
|
data/lib/cosmo/config.rb
CHANGED
|
@@ -47,10 +47,14 @@ module Cosmo
|
|
|
47
47
|
c[:duplicate_window] = to_ns(Utils::Duration.parse(c[:duplicate_window])) if c[:duplicate_window]
|
|
48
48
|
c[:subjects] = c[:subjects].map { |s| format(s, name: name) } if c[:subjects]
|
|
49
49
|
|
|
50
|
-
|
|
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
|
|
51
55
|
|
|
52
56
|
c[:allow_msg_schedules] = true
|
|
53
|
-
cron_subject = "#{API::Cron::Entry::SUBJECT_PREFIX}
|
|
57
|
+
cron_subject = "#{API::Cron::Entry::SUBJECT_PREFIX}.>"
|
|
54
58
|
c[:subjects] = Array(c[:subjects])
|
|
55
59
|
c[:subjects] << cron_subject unless c[:subjects].include?(cron_subject)
|
|
56
60
|
end
|
data/lib/cosmo/engine.rb
CHANGED
|
@@ -13,38 +13,137 @@ module Cosmo
|
|
|
13
13
|
instance.run(...)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Processor classes a +type+ starts: the matching one, or all of them when it names none.
|
|
17
|
+
#
|
|
18
|
+
# @param type [String, Symbol, nil]
|
|
19
|
+
# @return [Array<Class>]
|
|
20
|
+
def self.processors_for(type)
|
|
21
|
+
type && PROCESSORS.key?(type.to_sym) ? [PROCESSORS[type.to_sym]] : PROCESSORS.values
|
|
22
|
+
end
|
|
23
|
+
|
|
16
24
|
def self.instance
|
|
17
25
|
@instance ||= new
|
|
18
26
|
end
|
|
19
27
|
|
|
28
|
+
attr_reader :concurrency
|
|
29
|
+
|
|
20
30
|
def initialize
|
|
21
31
|
@concurrency = Config.fetch(:concurrency, 1)
|
|
22
32
|
@pool = Utils::ThreadPool.new(@concurrency)
|
|
23
33
|
@running = Concurrent::AtomicBoolean.new
|
|
34
|
+
@quiet = Concurrent::AtomicBoolean.new
|
|
35
|
+
@http_server = nil
|
|
36
|
+
@heartbeat = nil
|
|
24
37
|
end
|
|
25
38
|
|
|
26
39
|
def run(type, options)
|
|
27
|
-
handler = Utils::Signal.trap(:INT, :TERM)
|
|
40
|
+
handler = Utils::Signal.trap(:INT, :TERM, :TSTP, :CONT, :USR1, :TTIN)
|
|
28
41
|
Logger.info "Starting processing, hit Ctrl-C to stop [concurrency=#{@concurrency}]"
|
|
29
42
|
|
|
30
|
-
|
|
31
|
-
@processors = processor_classes.map { _1.run(@pool, @running, options) }
|
|
43
|
+
@processors = self.class.processors_for(type).map { _1.run(@pool, @running, options, quiet: @quiet) }
|
|
32
44
|
if @running.false?
|
|
33
45
|
Logger.warn "Shutting down... (No processors are running)"
|
|
34
46
|
return
|
|
35
47
|
end
|
|
36
48
|
|
|
37
|
-
|
|
49
|
+
@heartbeat = Heartbeat.new(self, options:).start
|
|
50
|
+
start_http_server
|
|
51
|
+
|
|
52
|
+
signal = handle_shutdown(handler)
|
|
38
53
|
Logger.info "Shutting down... (#{signal} received)"
|
|
39
54
|
shutdown
|
|
40
55
|
end
|
|
41
56
|
|
|
57
|
+
def running?
|
|
58
|
+
@running.true?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [String] +running+, +quiet+ (no new work fetched) or +stopping+
|
|
62
|
+
def state
|
|
63
|
+
return "stopping" unless running?
|
|
64
|
+
|
|
65
|
+
@quiet.true? ? "quiet" : "running"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Integer] threads processing messages right now, across all processors
|
|
69
|
+
def busy
|
|
70
|
+
Array(@processors).sum(&:busy)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Hash{Symbol => Array<String>}] what each running processor pulls from, keyed by processor type
|
|
74
|
+
def subscriptions
|
|
75
|
+
Array(@processors).to_h { [PROCESSORS.key(_1.class), _1.subscriptions] }
|
|
76
|
+
end
|
|
77
|
+
|
|
42
78
|
def shutdown
|
|
43
79
|
@running.make_false
|
|
80
|
+
@heartbeat&.beat
|
|
81
|
+
@http_server&.stop
|
|
44
82
|
@pool.shutdown
|
|
45
83
|
Logger.info "Pausing to allow jobs to finish..."
|
|
46
84
|
@pool.wait_for_termination(Config[:timeout])
|
|
85
|
+
@heartbeat&.stop
|
|
47
86
|
Logger.info "Bye!"
|
|
48
87
|
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# HTTPServer is autoloaded, so it's referenced only after the port is checked
|
|
92
|
+
# to keep its optional dependencies (rack, webrick) unloaded otherwise.
|
|
93
|
+
def start_http_server
|
|
94
|
+
port = Config.dig(:http, :port)
|
|
95
|
+
return unless port
|
|
96
|
+
|
|
97
|
+
host = Config.dig(:http, :host) || HTTPServer::DEFAULT_HOST
|
|
98
|
+
@http_server = HTTPServer.new(port: port, host: host).start
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def handle_shutdown(handler)
|
|
102
|
+
loop do
|
|
103
|
+
signal = handler.wait
|
|
104
|
+
case signal.to_s
|
|
105
|
+
when "TSTP" then quiet
|
|
106
|
+
when "CONT" then unquiet
|
|
107
|
+
when "USR1" then drain_and_exit(handler)
|
|
108
|
+
when "TTIN" then dump_threads
|
|
109
|
+
else return signal
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def quiet
|
|
115
|
+
return unless @quiet.make_true
|
|
116
|
+
|
|
117
|
+
Logger.info "Received TSTP, no new jobs will be fetched; finishing in-flight work"
|
|
118
|
+
@heartbeat&.beat
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def unquiet
|
|
122
|
+
return unless @quiet.make_false
|
|
123
|
+
|
|
124
|
+
Logger.info "Received CONT, resuming normal fetching"
|
|
125
|
+
@heartbeat&.beat
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def drain_and_exit(handler)
|
|
129
|
+
return unless @quiet.make_true
|
|
130
|
+
|
|
131
|
+
Logger.info "Received USR1, no new jobs will be fetched, exiting when work drains"
|
|
132
|
+
@heartbeat&.beat
|
|
133
|
+
Thread.new do
|
|
134
|
+
@pool.wait_idle
|
|
135
|
+
handler.push(:TERM)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def dump_threads
|
|
140
|
+
threads = Thread.list
|
|
141
|
+
Logger.warn "Received TTIN, dumping backtraces of #{threads.size} threads"
|
|
142
|
+
threads.each do |thread|
|
|
143
|
+
backtrace = thread.backtrace || ["<no backtrace available>"]
|
|
144
|
+
tid = (thread.object_id ^ ::Process.pid).to_s(36)
|
|
145
|
+
Logger.warn "Thread tid=#{tid} name=#{thread.name} [#{thread.status}]\n#{backtrace.join("\n")}"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
49
148
|
end
|
|
50
149
|
end
|