cosmonats 0.5.1 → 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 +102 -18
- 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 +69 -6
- 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/logger.rb +21 -3
- data/lib/cosmo/processor.rb +46 -16
- 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 +199 -8
- 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 +17 -2
- 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/logger.rbs +10 -0
- data/sig/cosmo/processor.rbs +12 -4
- 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,29 +1,62 @@
|
|
|
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)
|
|
22
50
|
js.publish(subject, payload, **params)
|
|
23
51
|
end
|
|
24
52
|
|
|
53
|
+
# Create a pull subscription. Durable with +consumer_name+, and ephemeral without.
|
|
54
|
+
# @param config [Hash] Consumer config. Ephemeral consumers additionally require
|
|
55
|
+
# +:stream+ and +:inactive_threshold+ (seconds the consumer survives without a fetch).
|
|
25
56
|
def subscribe(subject, consumer_name, config)
|
|
26
|
-
js.pull_subscribe(subject, consumer_name, config: config)
|
|
57
|
+
return js.pull_subscribe(subject, consumer_name, config: config) if consumer_name
|
|
58
|
+
|
|
59
|
+
ephemeral_subscribe(subject, config)
|
|
27
60
|
end
|
|
28
61
|
|
|
29
62
|
def stream_info(name)
|
|
@@ -103,6 +136,10 @@ module Cosmo
|
|
|
103
136
|
js.consumer_info(stream_name, consumer_name)
|
|
104
137
|
end
|
|
105
138
|
|
|
139
|
+
def delete_consumer(stream_name, consumer_name)
|
|
140
|
+
js.delete_consumer(stream_name, consumer_name)
|
|
141
|
+
end
|
|
142
|
+
|
|
106
143
|
def get_message(stream_name, **options)
|
|
107
144
|
js.get_msg(stream_name, **options)
|
|
108
145
|
end
|
|
@@ -133,6 +170,32 @@ module Cosmo
|
|
|
133
170
|
|
|
134
171
|
private
|
|
135
172
|
|
|
173
|
+
# NOTE: nats-pure's #pull_subscribe has no path to a true ephemeral pull consumer.
|
|
174
|
+
# Its rescue branch unconditionally sets `config[:durable_name] = durable` (jetstream.rb).
|
|
175
|
+
def ephemeral_subscribe(subject, config) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
176
|
+
config = config.dup
|
|
177
|
+
stream = config.delete(:stream) or raise ArgumentError, "stream required for ephemeral consumers"
|
|
178
|
+
raise ArgumentError, "inactive_threshold required for ephemeral consumers" unless config[:inactive_threshold]
|
|
179
|
+
|
|
180
|
+
subject = subject.first if subject.is_a?(Array) && subject.size == 1
|
|
181
|
+
consumer_config = NATS::JetStream::API::ConsumerConfig.new(config)
|
|
182
|
+
if subject.is_a?(Array)
|
|
183
|
+
consumer_config[:filter_subjects] ||= subject
|
|
184
|
+
else
|
|
185
|
+
consumer_config[:filter_subject] ||= subject
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
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
|
|
197
|
+
end
|
|
198
|
+
|
|
136
199
|
# NOTE: KV manager in nats-pure hardcodes the fields it copies into StreamConfig,
|
|
137
200
|
# so `allow_msg_ttl` is never forwarded via create_key_value. Send the raw stream-create API request instead.
|
|
138
201
|
def create_kv_with_msg_ttl(name, **options)
|
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
|