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
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
module Middleware
|
|
5
|
+
# Records every execution for the web UI's Metrics tab while +config.metrics+ is enabled: its run time when it
|
|
6
|
+
# succeeds, a failure otherwise, and the wait from enqueue to the job's first delivery. ActiveJob jobs count under
|
|
7
|
+
# their own class.
|
|
8
|
+
class Metrics
|
|
9
|
+
# @param job [Cosmo::Job]
|
|
10
|
+
# @param data [Hash]
|
|
11
|
+
# @param _message [NATS::Msg]
|
|
12
|
+
def call(job, data, _message) # rubocop:disable Metrics/AbcSize
|
|
13
|
+
return yield unless Config.metrics.enabled
|
|
14
|
+
|
|
15
|
+
name = job_name(job, data)
|
|
16
|
+
wait_ms = (Time.now - job.enqueued_at) * 1000 if job.attempt == 1 && job.enqueued_at
|
|
17
|
+
started = clock
|
|
18
|
+
result = yield
|
|
19
|
+
API::Stats::Metrics.instance.record(name, exec_ms: clock - started, wait_ms:)
|
|
20
|
+
result
|
|
21
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
22
|
+
API::Stats::Metrics.instance.record(name, wait_ms:, failed: true) if name
|
|
23
|
+
raise
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def job_name(job, data)
|
|
29
|
+
return job.class.name unless defined?(ActiveJobAdapter::Executor) && job.is_a?(ActiveJobAdapter::Executor)
|
|
30
|
+
|
|
31
|
+
data.dig(:args, 0, :job_class).to_s
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def clock
|
|
35
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize
|
|
4
|
+
module Cosmo
|
|
5
|
+
module Middleware
|
|
6
|
+
# Wraps every job execution in a Sentry transaction. Errors reach Sentry through {ERROR_HANDLER}, which also
|
|
7
|
+
# captures those outside jobs (stream batches, fetches, the scheduler). Needs +sentry-ruby+, so it is not loaded
|
|
8
|
+
# by default:
|
|
9
|
+
#
|
|
10
|
+
# require "cosmo/middleware/sentry"
|
|
11
|
+
#
|
|
12
|
+
# Cosmo.configure do |config|
|
|
13
|
+
# config.server_middleware { |chain| chain.add Cosmo::Middleware::Sentry }
|
|
14
|
+
# config.error_handlers << Cosmo::Middleware::Sentry::ERROR_HANDLER
|
|
15
|
+
# end
|
|
16
|
+
class Sentry
|
|
17
|
+
NAME_PREFIX = "Cosmonats"
|
|
18
|
+
OP_NAME = "queue.cosmonats"
|
|
19
|
+
SPAN_ORIGIN = "auto.queue.cosmonats"
|
|
20
|
+
STATUS_OK = 200
|
|
21
|
+
STATUS_FAIL = 500
|
|
22
|
+
|
|
23
|
+
ERROR_HANDLER = lambda do |error, context|
|
|
24
|
+
next unless ::Sentry.initialized?
|
|
25
|
+
|
|
26
|
+
::Sentry.capture_exception(error, contexts: { cosmonats: context }, hint: { background: true, integration: "cosmonats" })
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param job [Cosmo::Job]
|
|
30
|
+
# @param data [Hash]
|
|
31
|
+
# @param message [NATS::Msg]
|
|
32
|
+
def call(job, data, message)
|
|
33
|
+
return yield unless ::Sentry.initialized?
|
|
34
|
+
|
|
35
|
+
scope = ::Sentry.get_current_scope
|
|
36
|
+
transaction_name = "#{NAME_PREFIX}/#{job.class.name}"
|
|
37
|
+
scope.set_transaction_name(transaction_name, source: :task)
|
|
38
|
+
transaction = ::Sentry.start_transaction(
|
|
39
|
+
name: scope.transaction_name,
|
|
40
|
+
source: scope.transaction_source,
|
|
41
|
+
op: OP_NAME,
|
|
42
|
+
origin: SPAN_ORIGIN
|
|
43
|
+
)
|
|
44
|
+
transaction&.set_data("messaging.message.id", data[:jid])
|
|
45
|
+
transaction&.set_data("messaging.destination.name", "#{message.metadata.stream}:#{message.subject}")
|
|
46
|
+
transaction&.set_data("messaging.message.retry.count", data[:retry] || 0)
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
result = yield
|
|
50
|
+
transaction&.set_http_status(STATUS_OK)
|
|
51
|
+
transaction&.finish
|
|
52
|
+
result
|
|
53
|
+
rescue StandardError
|
|
54
|
+
transaction&.set_http_status(STATUS_FAIL)
|
|
55
|
+
transaction&.finish
|
|
56
|
+
raise
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
module Middleware
|
|
5
|
+
# Counts every execution as processed or failed, so a job retried twice and then succeeding adds two failures
|
|
6
|
+
# and one processed. A counter that cannot be written is logged rather than raised, so stats never fail a job.
|
|
7
|
+
class Totals
|
|
8
|
+
# @param _job [Cosmo::Job]
|
|
9
|
+
# @param _data [Hash]
|
|
10
|
+
# @param _message [NATS::Msg]
|
|
11
|
+
def call(_job, _data, _message)
|
|
12
|
+
result = yield
|
|
13
|
+
increment(:processed)
|
|
14
|
+
result
|
|
15
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
16
|
+
increment(:failed)
|
|
17
|
+
raise
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def increment(key)
|
|
23
|
+
API::Stats::Totals.instance.increment(key)
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
Logger.debug "Totals #{key} counter error: #{e.class} #{e.message}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/cosmo/processor.rb
CHANGED
|
@@ -5,20 +5,29 @@ module Cosmo
|
|
|
5
5
|
STREAM_PAUSED_RECHECK_TTL = "5s" # How long a stream's paused state is cached before re-checking (override via COSMO_STREAM_PAUSED_RECHECK_TTL)
|
|
6
6
|
STREAMS_PAUSED_IDLE_SLEEP = "1s" # How long to sleep when every stream is paused, preventing a tight CPU spin (override via COSMO_STREAMS_PAUSED_IDLE_SLEEP)
|
|
7
7
|
STREAM_EMPTY_BACKOFF_MAX = "5s" # Max sleep between empty fetches (override via COSMO_STREAM_EMPTY_BACKOFF_MAX)
|
|
8
|
+
QUIET_SLEEP = "5s" # How long to sleep between checks while in quiet mode (override via COSMO_QUIET_IDLE_SLEEP)
|
|
8
9
|
|
|
9
10
|
def self.run(...)
|
|
10
11
|
new(...).tap(&:run)
|
|
11
12
|
end
|
|
12
13
|
|
|
14
|
+
# Checks the command-line options this processor understands, before anything is booted or connected.
|
|
15
|
+
#
|
|
16
|
+
# @param options [Hash]
|
|
17
|
+
# @return [void]
|
|
18
|
+
def self.validate_options!(_options); end
|
|
19
|
+
|
|
13
20
|
attr_reader :consumers
|
|
14
21
|
|
|
15
|
-
def initialize(pool, running, options)
|
|
22
|
+
def initialize(pool, running, options, quiet:)
|
|
16
23
|
@pool = pool
|
|
17
24
|
@running = running
|
|
25
|
+
@quiet = quiet
|
|
18
26
|
@options = options
|
|
19
27
|
@threads = []
|
|
20
28
|
@consumers = []
|
|
21
29
|
@cache = Utils::TTLCache.new
|
|
30
|
+
@busy = Concurrent::AtomicFixnum.new
|
|
22
31
|
end
|
|
23
32
|
|
|
24
33
|
def run
|
|
@@ -29,6 +38,13 @@ module Cosmo
|
|
|
29
38
|
run_loop
|
|
30
39
|
end
|
|
31
40
|
|
|
41
|
+
# Threads processing fetched messages right now; threads waiting on a fetch don't count.
|
|
42
|
+
#
|
|
43
|
+
# @return [Integer]
|
|
44
|
+
def busy
|
|
45
|
+
@busy.value
|
|
46
|
+
end
|
|
47
|
+
|
|
32
48
|
def stop(timeout = Config[:timeout])
|
|
33
49
|
@running.make_false
|
|
34
50
|
@pool.shutdown
|
|
@@ -52,6 +68,11 @@ module Cosmo
|
|
|
52
68
|
while running?
|
|
53
69
|
break if shutdown
|
|
54
70
|
|
|
71
|
+
if quiet?
|
|
72
|
+
sleep(Utils::Duration.parse(ENV.fetch("COSMO_QUIET_IDLE_SLEEP", QUIET_SLEEP)))
|
|
73
|
+
next
|
|
74
|
+
end
|
|
75
|
+
|
|
55
76
|
all_empty = true # every stream is empty
|
|
56
77
|
all_paused = true # every stream is paused
|
|
57
78
|
consumers.each do |(subscription, config, processor)| # rubocop:disable Metrics/BlockLength
|
|
@@ -65,9 +86,10 @@ module Cosmo
|
|
|
65
86
|
end
|
|
66
87
|
all_paused = false
|
|
67
88
|
|
|
68
|
-
|
|
89
|
+
key = backoff_key(config)
|
|
90
|
+
_, skip_t = consumer_state[key]
|
|
69
91
|
if skip_t && Time.now < skip_t
|
|
70
|
-
Logger.trace "
|
|
92
|
+
Logger.trace "#{key} is empty, backing off"
|
|
71
93
|
next
|
|
72
94
|
end
|
|
73
95
|
all_empty = false
|
|
@@ -75,20 +97,23 @@ module Cosmo
|
|
|
75
97
|
begin
|
|
76
98
|
@pool.post do
|
|
77
99
|
# Re-check, after possibly being blocked on post to thread pool
|
|
78
|
-
_, skip_t = consumer_state[
|
|
100
|
+
_, skip_t = consumer_state[key]
|
|
79
101
|
next if skip_t && Time.now < skip_t
|
|
80
102
|
|
|
81
103
|
timeout = fetch_timeout(config)
|
|
82
104
|
Logger.trace "fetching #{fetch_subjects(config).inspect}, timeout=#{timeout}"
|
|
105
|
+
started_at = Time.now
|
|
83
106
|
messages = fetch(subscription, batch_size: config[:batch_size], timeout:)
|
|
84
107
|
Logger.trace "fetched (#{messages&.size.to_i}) messages"
|
|
85
108
|
if messages&.any?
|
|
86
|
-
consumer_state.delete(
|
|
87
|
-
process(messages, processor)
|
|
109
|
+
consumer_state.delete(key)
|
|
110
|
+
track_busy { process(messages, processor) }
|
|
88
111
|
else
|
|
89
112
|
max_backoff = Utils::Duration.parse(ENV.fetch("COSMO_STREAM_EMPTY_BACKOFF_MAX", STREAM_EMPTY_BACKOFF_MAX))
|
|
90
|
-
consumer_state.compute(
|
|
91
|
-
count
|
|
113
|
+
consumer_state.compute(key) do |(count, wake_at)|
|
|
114
|
+
next [count, wake_at] if wake_at && wake_at > started_at
|
|
115
|
+
|
|
116
|
+
count = count.to_i + 1
|
|
92
117
|
backoff = [timeout * (2**(count - 1)), max_backoff].min
|
|
93
118
|
[count, Time.now + backoff]
|
|
94
119
|
end
|
|
@@ -129,10 +154,21 @@ module Cosmo
|
|
|
129
154
|
raise NotImplementedError
|
|
130
155
|
end
|
|
131
156
|
|
|
157
|
+
def track_busy
|
|
158
|
+
@busy.increment
|
|
159
|
+
yield
|
|
160
|
+
ensure
|
|
161
|
+
@busy.decrement
|
|
162
|
+
end
|
|
163
|
+
|
|
132
164
|
def running?
|
|
133
165
|
@running.true?
|
|
134
166
|
end
|
|
135
167
|
|
|
168
|
+
def quiet?
|
|
169
|
+
@quiet.true?
|
|
170
|
+
end
|
|
171
|
+
|
|
136
172
|
def scheduler?
|
|
137
173
|
false
|
|
138
174
|
end
|
|
@@ -144,6 +180,7 @@ module Cosmo
|
|
|
144
180
|
rescue StandardError => e
|
|
145
181
|
Logger.error "Snap! Error just happened"
|
|
146
182
|
Logger.error "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
|
|
183
|
+
Cosmo.handle_error(e, { source: :fetch })
|
|
147
184
|
|
|
148
185
|
backoff = ENV.fetch("COSMO_STREAMS_FETCH_BACKOFF", 5).to_f
|
|
149
186
|
sleep([timeout, backoff].max) # backoff before retry
|
|
@@ -161,5 +198,9 @@ module Cosmo
|
|
|
161
198
|
def consumer_state
|
|
162
199
|
@consumer_state ||= Concurrent::Map.new
|
|
163
200
|
end
|
|
201
|
+
|
|
202
|
+
def backoff_key(config)
|
|
203
|
+
config[:stream].to_s
|
|
204
|
+
end
|
|
164
205
|
end
|
|
165
206
|
end
|
data/lib/cosmo/publisher.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Cosmo
|
|
|
7
7
|
class << self
|
|
8
8
|
extend Forwardable
|
|
9
9
|
|
|
10
|
-
delegate %i[publish
|
|
10
|
+
delegate %i[publish] => :instance
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def self.instance
|
|
@@ -22,17 +22,5 @@ module Cosmo
|
|
|
22
22
|
payload = (serializer || Stream::Serializer).serialize(data)
|
|
23
23
|
@client.publish(subject, payload, **options)
|
|
24
24
|
end
|
|
25
|
-
|
|
26
|
-
def publish_job(data)
|
|
27
|
-
subject, payload, params = data.to_args
|
|
28
|
-
@client.publish(subject, payload, **params)
|
|
29
|
-
data.jid
|
|
30
|
-
rescue NATS::JetStream::Error::NoStreamResponse
|
|
31
|
-
raise StreamNotFoundError, params[:stream].to_s
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def publish_batch(subject, batch, **options)
|
|
35
|
-
batch.each { publish(subject, _1, **options) }
|
|
36
|
-
end
|
|
37
25
|
end
|
|
38
26
|
end
|
data/lib/cosmo/railtie.rb
CHANGED
|
@@ -12,10 +12,10 @@ module Cosmo
|
|
|
12
12
|
require "cosmo/active_job"
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
#
|
|
15
|
+
# Loads config/cosmo.yml, or the built-in defaults without one, unless a config has been loaded already.
|
|
16
16
|
initializer "cosmo.load_config", after: "cosmo.active_job_adapter" do |app|
|
|
17
17
|
config_path = app.root.join("config", "cosmo.yml")
|
|
18
|
-
Config.load(config_path.
|
|
18
|
+
Config.load(config_path.exist? ? config_path.to_s : nil) if Config.instance.none?
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
# The streams Cosmo itself depends on. They are built from the settings in Cosmo.configure, never from cosmo.yml,
|
|
5
|
+
# and `cosmo --setup` creates them next to the user's streams.
|
|
6
|
+
module Services
|
|
7
|
+
SCHEDULED = Job::StreamFilter::SCHEDULED.to_s
|
|
8
|
+
DEAD = "dead"
|
|
9
|
+
METADATA = { "_cosmo.type" => "jobs" }.freeze
|
|
10
|
+
RETENTION_CHANGE = 10_052
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Holds delayed jobs and every cron template. It alone enables NATS message scheduling, since a schedule can only
|
|
15
|
+
# target a subject its own stream covers, and NATS refuses +discard: new+ wherever scheduling is enabled.
|
|
16
|
+
#
|
|
17
|
+
# @return [Hash, nil] nil when scheduling is turned off
|
|
18
|
+
def scheduled_stream
|
|
19
|
+
return unless Config.scheduled.enabled
|
|
20
|
+
|
|
21
|
+
{ storage: "file", retention: "workqueue", discard: "old", allow_direct: true, allow_msg_schedules: true,
|
|
22
|
+
duplicate_window: Config.to_ns(120), subjects: ["jobs.#{SCHEDULED}.>", "#{API::Cron::Entry::SUBJECT_PREFIX}.>"],
|
|
23
|
+
num_replicas: Config.replicas, description: "Scheduled jobs and cron schedules", metadata: METADATA }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# +max_deliver+ stays above 1: the scheduler naks a message that is not yet due, and one whose dispatch failed,
|
|
27
|
+
# and both need another delivery.
|
|
28
|
+
#
|
|
29
|
+
# @return [Hash, nil] nil when scheduling is turned off
|
|
30
|
+
def scheduled_consumer
|
|
31
|
+
return unless Config.scheduled.enabled
|
|
32
|
+
|
|
33
|
+
{ ack_policy: "explicit", max_deliver: 5, max_ack_pending: 100, ack_wait: 10, subject: "jobs.#{SCHEDULED}.>" }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Hash, nil] nil when dead-lettering is turned off
|
|
37
|
+
def dead_stream
|
|
38
|
+
dead = Config.dead
|
|
39
|
+
return unless dead.enabled
|
|
40
|
+
|
|
41
|
+
{ storage: "file", retention: "workqueue", discard: "old", allow_direct: true, duplicate_window: Config.to_ns(120),
|
|
42
|
+
max_age: Config.to_ns(Utils::Duration.parse(dead.max_age)), max_msgs: dead.max_msgs, max_bytes: dead.max_bytes,
|
|
43
|
+
subjects: ["jobs.#{DEAD}.>"], num_replicas: Config.replicas, description: "Dead jobs", metadata: METADATA }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Creates or updates every enabled service stream, plus the internal totals and batch counters and, while enabled,
|
|
47
|
+
# the job metrics.
|
|
48
|
+
#
|
|
49
|
+
# @return [Array<String>] the names of the service streams set up, without the internal ones
|
|
50
|
+
def setup!
|
|
51
|
+
streams = { SCHEDULED => scheduled_stream, DEAD => dead_stream }.compact
|
|
52
|
+
streams.each { |name, config| setup_stream(name, config) }
|
|
53
|
+
API::Stats::Totals.setup!
|
|
54
|
+
Batch::Counters.setup!
|
|
55
|
+
API::Stats::Metrics.setup! if Config.metrics.enabled
|
|
56
|
+
streams.keys
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @raise [Error] when an existing stream has another retention policy, which NATS can't change in place
|
|
60
|
+
def setup_stream(name, config)
|
|
61
|
+
Client.instance.setup_stream(name, config)
|
|
62
|
+
rescue NATS::JetStream::Error::APIError => e
|
|
63
|
+
raise unless e.err_code == RETENTION_CHANGE
|
|
64
|
+
|
|
65
|
+
raise Error, "The existing `#{name}` stream has another retention policy, which NATS can't change in place: " \
|
|
66
|
+
"delete it (`nats stream rm #{name}`, dropping its messages) and rerun `cosmo --setup`"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
data/lib/cosmo/stream/message.rb
CHANGED
|
@@ -7,8 +7,9 @@ module Cosmo
|
|
|
7
7
|
class Message
|
|
8
8
|
extend Forwardable
|
|
9
9
|
|
|
10
|
-
delegate %i[subject reply header metadata ack
|
|
10
|
+
delegate %i[subject reply header metadata ack nak term in_progress] => :@msg
|
|
11
11
|
delegate %i[timestamp num_delivered num_pending] => :metadata
|
|
12
|
+
alias nack nak
|
|
12
13
|
|
|
13
14
|
def initialize(msg, serializer: nil)
|
|
14
15
|
@msg = msg
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
module Cosmo
|
|
4
4
|
module Stream
|
|
5
5
|
class Processor < ::Cosmo::Processor
|
|
6
|
+
# @return [Array<String>] names of the stream classes being processed
|
|
7
|
+
def subscriptions
|
|
8
|
+
@consumers.map { |(_, config, _)| config[:class].name }
|
|
9
|
+
end
|
|
10
|
+
|
|
6
11
|
private
|
|
7
12
|
|
|
8
13
|
def setup
|
|
@@ -33,8 +38,10 @@ module Cosmo
|
|
|
33
38
|
processor.process(messages)
|
|
34
39
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "done" }
|
|
35
40
|
rescue StandardError => e
|
|
36
|
-
Logger.
|
|
41
|
+
Logger.error e
|
|
37
42
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
|
|
43
|
+
Cosmo.handle_error(e, { source: :stream, stream: metadata&.stream, processor: processor.class.name,
|
|
44
|
+
size: messages&.size })
|
|
38
45
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
39
46
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
|
|
40
47
|
raise
|
|
@@ -65,6 +72,10 @@ module Cosmo
|
|
|
65
72
|
config.dig(:consumer, :subjects)
|
|
66
73
|
end
|
|
67
74
|
|
|
75
|
+
def backoff_key(config)
|
|
76
|
+
config[:consumer_name] || config[:class].name
|
|
77
|
+
end
|
|
78
|
+
|
|
68
79
|
def fetch_timeout(config)
|
|
69
80
|
timeout = config[:fetch_timeout].to_f
|
|
70
81
|
if timeout <= 0
|
data/lib/cosmo/utils/hash.rb
CHANGED
|
@@ -36,14 +36,13 @@ module Cosmo
|
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
target[last_key] = value
|
|
39
|
+
# Nested hashes merge recursively, +other+ winning; any other value in +other+ replaces the one in +base+.
|
|
40
|
+
#
|
|
41
|
+
# @param base [::Hash]
|
|
42
|
+
# @param other [::Hash]
|
|
43
|
+
# @return [::Hash] a new hash
|
|
44
|
+
def deep_merge(base, other)
|
|
45
|
+
base.merge(other) { |_, old, new| old.is_a?(::Hash) && new.is_a?(::Hash) ? deep_merge(old, new) : new }
|
|
47
46
|
end
|
|
48
47
|
|
|
49
48
|
# deep dup
|
data/lib/cosmo/utils/json.rb
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
Cosmo::Utils::Warnings.silence do
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
unless NATS::JetStream::API::StreamConfig.members.include?(:allow_msg_schedules)
|
|
5
|
+
members = NATS::JetStream::API::StreamConfig.members + %i[allow_msg_counter allow_msg_schedules]
|
|
6
|
+
NATS::JetStream::API::StreamConfig = Struct.new(*members, keyword_init: true) do
|
|
7
|
+
def initialize(opts = {})
|
|
8
|
+
rem = opts.keys - members
|
|
9
|
+
opts.delete_if { |k| rem.include?(k) }
|
|
10
|
+
super
|
|
11
|
+
end
|
|
10
12
|
end
|
|
11
13
|
end
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
unless NATS::JetStream::PubAck.members.include?(:val)
|
|
16
|
+
members = NATS::JetStream::PubAck.members + [:val]
|
|
17
|
+
NATS::JetStream::PubAck = Struct.new(*members, keyword_init: true)
|
|
18
|
+
end
|
|
15
19
|
end
|
|
16
20
|
|
|
17
|
-
# Upstream bug in nats-pure 2.5.0 (https://github.com/nats-io/nats.rb):
|
|
21
|
+
# Upstream bug in nats-pure 2.5.0 and 2.6.0 (https://github.com/nats-io/nats-pure.rb):
|
|
18
22
|
# NATS::JetStream::PullSubscription#fetch only assigns its `start_time` local in the
|
|
19
23
|
# `batch > 1` branch, but the final "did we time out" check outside the case statement
|
|
20
24
|
# reads it unconditionally. For `batch == 1` (what Cosmo::Processor always uses),
|
|
@@ -44,7 +48,9 @@ end
|
|
|
44
48
|
#
|
|
45
49
|
# Vendored copy of nats-pure's PullSubscription#fetch, kept as close to
|
|
46
50
|
# upstream as possible (two one-line fixes, see comments above), so it's easy to diff against the
|
|
47
|
-
# next nats-pure release and drop once fixed there.
|
|
51
|
+
# next nats-pure release and drop once fixed there. Fixed on nats-pure main by the fetch rewrite
|
|
52
|
+
# (#181), recognized by its private #take_pending, in which case this copy is not loaded.
|
|
53
|
+
return if NATS::JetStream.const_get(:PullSubscription).private_method_defined?(:take_pending)
|
|
48
54
|
|
|
49
55
|
# rubocop:disable all
|
|
50
56
|
module NATS
|
|
@@ -16,11 +16,16 @@ module Cosmo
|
|
|
16
16
|
|
|
17
17
|
def initialize(concurrency)
|
|
18
18
|
@mutex = Thread::Mutex.new
|
|
19
|
+
@concurrency = concurrency
|
|
19
20
|
@available = concurrency
|
|
20
21
|
@cond = ConditionVariable.new
|
|
21
22
|
@pool = Concurrent::FixedThreadPool.new(concurrency)
|
|
22
23
|
end
|
|
23
24
|
|
|
25
|
+
def wait_idle
|
|
26
|
+
@mutex.synchronize { @cond.wait(@mutex) until @available == @concurrency }
|
|
27
|
+
end
|
|
28
|
+
|
|
24
29
|
def post
|
|
25
30
|
@mutex.synchronize do
|
|
26
31
|
@cond.wait(@mutex) while @available <= 0
|
data/lib/cosmo/version.rb
CHANGED
|
@@ -163,6 +163,14 @@ button:hover, .btn:hover {
|
|
|
163
163
|
);
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
.btn-sm {
|
|
167
|
+
box-shadow: none;
|
|
168
|
+
font-size: var(--font-size-small);
|
|
169
|
+
font-weight: 600;
|
|
170
|
+
line-height: var(--space-2x);
|
|
171
|
+
padding: var(--space-1-2) var(--space);
|
|
172
|
+
}
|
|
173
|
+
|
|
166
174
|
/* ── Badges ────────────────────────────────────────────────────────────── */
|
|
167
175
|
.badge {
|
|
168
176
|
border-radius: var(--space-1-2);
|
|
@@ -343,6 +351,12 @@ section .pending {
|
|
|
343
351
|
align-items: center;
|
|
344
352
|
}
|
|
345
353
|
|
|
354
|
+
section .pending .poll-status {
|
|
355
|
+
align-items: center;
|
|
356
|
+
display: inline-flex;
|
|
357
|
+
gap: var(--space);
|
|
358
|
+
}
|
|
359
|
+
|
|
346
360
|
/* ── Stat cards ────────────────────────────────────────────────────────── */
|
|
347
361
|
.cards-container {
|
|
348
362
|
display: grid;
|
|
@@ -425,6 +439,15 @@ td {
|
|
|
425
439
|
tr:last-child td { border-bottom: none; }
|
|
426
440
|
tr:hover { background: var(--color-table-bg-alt); }
|
|
427
441
|
|
|
442
|
+
tr.group-row td,
|
|
443
|
+
tr.group-row:hover { background: var(--color-table-bg-alt); }
|
|
444
|
+
tr.group-row td {
|
|
445
|
+
color: var(--color-text-light);
|
|
446
|
+
font-size: var(--font-size-small);
|
|
447
|
+
font-weight: 600;
|
|
448
|
+
padding: var(--space) var(--space-2x);
|
|
449
|
+
}
|
|
450
|
+
|
|
428
451
|
/* ── Subjects ──────────────────────────────────────────────────────────── */
|
|
429
452
|
.subjects { display: flex; flex-wrap: wrap; gap: var(--space-1-2); }
|
|
430
453
|
|
|
@@ -479,6 +502,13 @@ time {
|
|
|
479
502
|
font-size: var(--font-size-small);
|
|
480
503
|
word-wrap: break-word;
|
|
481
504
|
}
|
|
505
|
+
.error-message details code {
|
|
506
|
+
max-width: 600px;
|
|
507
|
+
max-height: 320px;
|
|
508
|
+
overflow: auto;
|
|
509
|
+
white-space: pre;
|
|
510
|
+
font-size: var(--font-size-small);
|
|
511
|
+
}
|
|
482
512
|
|
|
483
513
|
/* ── Links ─────────────────────────────────────────────────────────────── */
|
|
484
514
|
.stream-link { color: var(--color-primary); font-weight: 600; }
|
|
@@ -518,6 +548,15 @@ details summary {
|
|
|
518
548
|
details summary code { display: inline; user-select: text; }
|
|
519
549
|
details[open] summary { margin-bottom: var(--space); }
|
|
520
550
|
details code { display: block; max-width: 400px; white-space: pre-wrap; }
|
|
551
|
+
details summary:has(> code.truncate) { white-space: nowrap; }
|
|
552
|
+
details summary code.truncate {
|
|
553
|
+
display: inline-block;
|
|
554
|
+
max-width: 32ch;
|
|
555
|
+
overflow: hidden;
|
|
556
|
+
text-overflow: ellipsis;
|
|
557
|
+
vertical-align: middle;
|
|
558
|
+
white-space: nowrap;
|
|
559
|
+
}
|
|
521
560
|
|
|
522
561
|
/* ── HTMX ──────────────────────────────────────────────────────────────── */
|
|
523
562
|
.htmx-indicator { opacity: 0; transition: opacity 200ms; }
|
|
@@ -542,6 +581,46 @@ details code { display: block; max-width: 400px; white-space: pre-wrap; }
|
|
|
542
581
|
}
|
|
543
582
|
|
|
544
583
|
/* ── Responsive ────────────────────────────────────────────────────────── */
|
|
584
|
+
|
|
585
|
+
.metrics-chart {
|
|
586
|
+
background: var(--color-elevated);
|
|
587
|
+
border-radius: var(--space-1-2);
|
|
588
|
+
box-shadow: 0 1px 3px var(--color-shadow);
|
|
589
|
+
padding: var(--space-2x) var(--space-3x);
|
|
590
|
+
margin-bottom: var(--space-3x);
|
|
591
|
+
}
|
|
592
|
+
.metrics-chart svg { width: 100%; height: auto; display: block; }
|
|
593
|
+
.metrics-chart-header {
|
|
594
|
+
display: flex;
|
|
595
|
+
justify-content: space-between;
|
|
596
|
+
flex-wrap: wrap;
|
|
597
|
+
gap: var(--space);
|
|
598
|
+
margin-bottom: var(--space);
|
|
599
|
+
}
|
|
600
|
+
.metrics-legend { display: flex; gap: var(--space-2x); font-size: var(--font-size-small); color: var(--color-text-light); }
|
|
601
|
+
.metrics-legend span::before {
|
|
602
|
+
content: "";
|
|
603
|
+
display: inline-block;
|
|
604
|
+
width: 10px;
|
|
605
|
+
height: 10px;
|
|
606
|
+
margin-right: 6px;
|
|
607
|
+
border-radius: 2px;
|
|
608
|
+
}
|
|
609
|
+
.metrics-legend-runs::before { background: var(--color-primary); opacity: 0.45; }
|
|
610
|
+
.metrics-legend-exec::before { background: var(--color-warning); }
|
|
611
|
+
.chart-grid { stroke: var(--color-border); stroke-width: 1; }
|
|
612
|
+
.chart-axis { fill: var(--color-text-light); font-size: 11px; font-family: var(--font-sans); }
|
|
613
|
+
.chart-bar { fill: var(--color-primary); opacity: 0.45; }
|
|
614
|
+
.chart-bar:hover { opacity: 0.7; }
|
|
615
|
+
.chart-line { fill: none; stroke: var(--color-warning); stroke-width: 2; }
|
|
616
|
+
.chart-point { fill: var(--color-warning); stroke: var(--color-elevated); stroke-width: 2; }
|
|
617
|
+
.metrics-hint { margin: 0 0 var(--space); font-size: var(--font-size-small); color: var(--color-text-light); }
|
|
618
|
+
.metrics-table tr.selectable { cursor: pointer; }
|
|
619
|
+
.metrics-table tr.selectable:hover { background: var(--color-selected); }
|
|
620
|
+
.metrics-table tr.selected { background: var(--color-selected); }
|
|
621
|
+
.metrics-table tr.selected td:first-child { box-shadow: inset 3px 0 0 var(--color-primary); }
|
|
622
|
+
.metrics-table tr.selected code { color: var(--color-primary); }
|
|
623
|
+
|
|
545
624
|
@media (max-width: 768px) {
|
|
546
625
|
.nav { flex-wrap: wrap; }
|
|
547
626
|
.nav-list { order: 3; width: 100%; margin-top: var(--space); }
|