cosmonats 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +219 -113
- data/lib/cosmo/active_job/adapter.rb +1 -2
- data/lib/cosmo/api/counter.rb +17 -17
- data/lib/cosmo/api/cron/entry.rb +10 -11
- data/lib/cosmo/api/cron.rb +6 -3
- data/lib/cosmo/api/job.rb +1 -1
- data/lib/cosmo/api/kv.rb +3 -3
- data/lib/cosmo/api/registry.rb +1 -1
- data/lib/cosmo/api/stats/busy.rb +6 -1
- data/lib/cosmo/api/stats/metrics.rb +109 -0
- data/lib/cosmo/api/stats/processes.rb +14 -4
- data/lib/cosmo/api/stats/totals.rb +4 -12
- data/lib/cosmo/api/stats.rb +18 -2
- data/lib/cosmo/api/stream.rb +7 -2
- data/lib/cosmo/batch/counters.rb +19 -0
- data/lib/cosmo/batch.rb +6 -6
- data/lib/cosmo/cli.rb +21 -5
- data/lib/cosmo/client.rb +37 -14
- data/lib/cosmo/config/cosmo.yml +58 -0
- data/lib/cosmo/config/settings.rb +20 -0
- data/lib/cosmo/config.rb +144 -19
- data/lib/cosmo/heartbeat.rb +11 -0
- data/lib/cosmo/job/data.rb +17 -12
- data/lib/cosmo/job/enqueuer.rb +53 -0
- data/lib/cosmo/job/limit.rb +2 -2
- data/lib/cosmo/job/processor.rb +32 -58
- data/lib/cosmo/job/requeue.rb +20 -0
- data/lib/cosmo/job.rb +8 -22
- data/lib/cosmo/logger.rb +15 -2
- data/lib/cosmo/middleware/busy.rb +15 -0
- data/lib/cosmo/middleware/chain.rb +137 -0
- data/lib/cosmo/middleware/limit.rb +50 -0
- data/lib/cosmo/middleware/metrics.rb +39 -0
- data/lib/cosmo/{sentry/job_processor_middleware.rb → middleware/sentry.rb} +26 -30
- data/lib/cosmo/middleware/totals.rb +29 -0
- data/lib/cosmo/middleware.rb +7 -0
- data/lib/cosmo/processor.rb +11 -5
- data/lib/cosmo/publisher.rb +1 -13
- data/lib/cosmo/railtie.rb +2 -2
- data/lib/cosmo/services.rb +69 -0
- data/lib/cosmo/stream/processor.rb +7 -1
- data/lib/cosmo/utils/hash.rb +7 -8
- data/lib/cosmo/utils.rb +0 -1
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +271 -571
- data/lib/cosmo/web/assets/app.js +158 -0
- data/lib/cosmo/web/assets/fonts/OFL.txt +92 -0
- data/lib/cosmo/web/assets/fonts/geist-mono.woff2 +0 -0
- data/lib/cosmo/web/assets/fonts/geist.woff2 +0 -0
- data/lib/cosmo/web/assets/logo.svg +10 -0
- data/lib/cosmo/web/cache.rb +75 -0
- data/lib/cosmo/web/chart.rb +122 -0
- data/lib/cosmo/web/circuit.rb +55 -0
- data/lib/cosmo/web/context.rb +0 -4
- data/lib/cosmo/web/controllers/application.rb +16 -0
- data/lib/cosmo/web/controllers/batches.rb +1 -0
- data/lib/cosmo/web/controllers/crons.rb +17 -2
- data/lib/cosmo/web/controllers/jobs.rb +59 -54
- data/lib/cosmo/web/controllers/metrics.rb +49 -0
- data/lib/cosmo/web/controllers/overview.rb +70 -0
- data/lib/cosmo/web/controllers/processes.rb +33 -0
- data/lib/cosmo/web/controllers/streams.rb +26 -28
- data/lib/cosmo/web/helpers/application.rb +67 -0
- data/lib/cosmo/web/renderer.rb +19 -6
- data/lib/cosmo/web/throughput.rb +104 -0
- data/lib/cosmo/web/views/batches/_table.erb +42 -43
- data/lib/cosmo/web/views/batches/index.erb +3 -10
- data/lib/cosmo/web/views/crons/_table.erb +32 -53
- data/lib/cosmo/web/views/crons/index.erb +2 -9
- data/lib/cosmo/web/views/jobs/_args.erb +5 -0
- data/lib/cosmo/web/views/jobs/_busy.erb +25 -121
- data/lib/cosmo/web/views/jobs/_dead.erb +42 -82
- data/lib/cosmo/web/views/jobs/_enqueued.erb +33 -113
- data/lib/cosmo/web/views/jobs/_panel.erb +29 -0
- data/lib/cosmo/web/views/jobs/_queue_row.erb +18 -0
- data/lib/cosmo/web/views/jobs/_queues.erb +20 -0
- data/lib/cosmo/web/views/jobs/_scheduled.erb +20 -54
- data/lib/cosmo/web/views/jobs/index.erb +4 -17
- data/lib/cosmo/web/views/layout.erb +73 -22
- data/lib/cosmo/web/views/metrics/_chart.erb +41 -0
- data/lib/cosmo/web/views/metrics/_panel.erb +13 -0
- data/lib/cosmo/web/views/metrics/_table.erb +25 -0
- data/lib/cosmo/web/views/metrics/index.erb +9 -0
- data/lib/cosmo/web/views/overview/_badges.erb +3 -0
- data/lib/cosmo/web/views/overview/_kpis.erb +19 -0
- data/lib/cosmo/web/views/overview/_running.erb +18 -0
- data/lib/cosmo/web/views/overview/_throughput.erb +50 -0
- data/lib/cosmo/web/views/overview/_workers.erb +25 -0
- data/lib/cosmo/web/views/overview/index.erb +28 -0
- data/lib/cosmo/web/views/processes/_table.erb +67 -0
- data/lib/cosmo/web/views/processes/index.erb +1 -0
- data/lib/cosmo/web/views/shared/_pager.erb +22 -0
- data/lib/cosmo/web/views/shared/unavailable.erb +7 -0
- data/lib/cosmo/web/views/streams/_info.erb +28 -86
- data/lib/cosmo/web/views/streams/_list.erb +7 -0
- data/lib/cosmo/web/views/streams/_pause_banner.erb +8 -16
- data/lib/cosmo/web/views/streams/_stream.erb +52 -0
- data/lib/cosmo/web/views/streams/index.erb +3 -11
- data/lib/cosmo/web/views/streams/info.erb +3 -11
- data/lib/cosmo/web.rb +67 -33
- data/lib/cosmo.rb +38 -0
- data/sig/cosmo/api/counter.rbs +2 -3
- data/sig/cosmo/api/cron/entry.rbs +2 -1
- data/sig/cosmo/api/stats/busy.rbs +3 -1
- data/sig/cosmo/api/stats/metrics.rbs +42 -0
- data/sig/cosmo/api/stats/processes.rbs +3 -0
- data/sig/cosmo/api/stats/totals.rbs +3 -2
- data/sig/cosmo/api/stats.rbs +4 -0
- data/sig/cosmo/api/stream.rbs +2 -0
- data/sig/cosmo/batch/counters.rbs +10 -0
- data/sig/cosmo/batch.rbs +3 -4
- data/sig/cosmo/cli.rbs +2 -0
- data/sig/cosmo/client.rbs +4 -1
- data/sig/cosmo/config/settings.rbs +31 -0
- data/sig/cosmo/config.rbs +59 -5
- data/sig/cosmo/heartbeat.rbs +2 -0
- data/sig/cosmo/job/data.rbs +5 -3
- data/sig/cosmo/job/enqueuer.rbs +11 -0
- data/sig/cosmo/job/processor.rbs +3 -5
- data/sig/cosmo/job/requeue.rbs +9 -0
- data/sig/cosmo/job.rbs +0 -2
- data/sig/cosmo/logger.rbs +6 -0
- data/sig/cosmo/middleware/chain.rbs +51 -0
- data/sig/cosmo/middleware/sentry.rbs +14 -0
- data/sig/cosmo/middleware.rbs +35 -0
- data/sig/cosmo/processor.rbs +2 -0
- data/sig/cosmo/publisher.rbs +1 -9
- data/sig/cosmo/services.rbs +18 -0
- data/sig/cosmo/stream/processor.rbs +2 -0
- data/sig/cosmo/utils/hash.rbs +2 -3
- data/sig/cosmo.rbs +11 -0
- metadata +60 -20
- data/lib/cosmo/sentry/auto.rb +0 -6
- data/lib/cosmo/utils/overrides.rb +0 -206
- data/lib/cosmo/utils/warnings.rb +0 -17
- data/lib/cosmo/web/controllers/actions.rb +0 -16
- data/lib/cosmo/web/views/actions/index.erb +0 -7
- data/lib/cosmo/web/views/jobs/_processes.erb +0 -131
- data/lib/cosmo/web/views/jobs/_stats.erb +0 -73
- data/lib/cosmo/web/views/jobs/_tabs.erb +0 -8
- data/lib/cosmo/web/views/jobs/busy.erb +0 -18
- data/lib/cosmo/web/views/jobs/dead.erb +0 -18
- data/lib/cosmo/web/views/jobs/enqueued.erb +0 -18
- data/lib/cosmo/web/views/jobs/scheduled.erb +0 -18
- data/lib/cosmo/web/views/streams/_stream_row.erb +0 -42
- data/lib/cosmo/web/views/streams/_table.erb +0 -25
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
module Middleware
|
|
5
|
+
# An ordered list of middleware classes wrapped around job execution, outermost first.
|
|
6
|
+
#
|
|
7
|
+
# A middleware is any class whose instances respond to +call(job, data, message)+ and yield to run
|
|
8
|
+
# the rest of the chain. Every invocation builds fresh instances, so a middleware may keep per-job
|
|
9
|
+
# state in instance variables. Adding a class that is already in the chain moves it instead.
|
|
10
|
+
#
|
|
11
|
+
# class Timing
|
|
12
|
+
# def initialize(threshold:) = @threshold = threshold
|
|
13
|
+
#
|
|
14
|
+
# def call(job, data, message)
|
|
15
|
+
# yield
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# Cosmo.configure do |config|
|
|
20
|
+
# config.server_middleware { |с| с.add Timing, threshold: 5 }
|
|
21
|
+
# end
|
|
22
|
+
class Chain
|
|
23
|
+
include Enumerable
|
|
24
|
+
|
|
25
|
+
Entry = Struct.new(:klass, :args, :kwargs) do
|
|
26
|
+
def build
|
|
27
|
+
klass.new(*args, **kwargs)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @yieldparam chain [Chain]
|
|
32
|
+
def initialize
|
|
33
|
+
@entries = []
|
|
34
|
+
yield self if block_given?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @yieldparam entry [Entry]
|
|
38
|
+
def each(&)
|
|
39
|
+
@entries.each(&)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Appends +klass+, so it runs innermost, closest to the job.
|
|
43
|
+
#
|
|
44
|
+
# @param klass [Class]
|
|
45
|
+
# @return [Chain]
|
|
46
|
+
def add(klass, *args, **kwargs)
|
|
47
|
+
remove(klass)
|
|
48
|
+
@entries << Entry.new(klass, args, kwargs)
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Inserts +klass+ first, so it runs outermost.
|
|
53
|
+
#
|
|
54
|
+
# @param klass [Class]
|
|
55
|
+
# @return [Chain]
|
|
56
|
+
def prepend(klass, *args, **kwargs)
|
|
57
|
+
remove(klass)
|
|
58
|
+
@entries.unshift(Entry.new(klass, args, kwargs))
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Inserts +klass+ right before +existing+, or first when +existing+ is not in the chain.
|
|
63
|
+
#
|
|
64
|
+
# @param existing [Class]
|
|
65
|
+
# @param klass [Class]
|
|
66
|
+
# @return [Chain]
|
|
67
|
+
def insert_before(existing, klass, *args, **kwargs)
|
|
68
|
+
entry = take(klass, args, kwargs)
|
|
69
|
+
@entries.insert(index(existing) || 0, entry)
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Inserts +klass+ right after +existing+, or last when +existing+ is not in the chain.
|
|
74
|
+
#
|
|
75
|
+
# @param existing [Class]
|
|
76
|
+
# @param klass [Class]
|
|
77
|
+
# @return [Chain]
|
|
78
|
+
def insert_after(existing, klass, *args, **kwargs)
|
|
79
|
+
entry = take(klass, args, kwargs)
|
|
80
|
+
position = index(existing)
|
|
81
|
+
@entries.insert(position ? position + 1 : @entries.size, entry)
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @param klass [Class]
|
|
86
|
+
# @return [Chain]
|
|
87
|
+
def remove(klass)
|
|
88
|
+
@entries.delete_if { _1.klass == klass }
|
|
89
|
+
self
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @param klass [Class]
|
|
93
|
+
# @return [Boolean]
|
|
94
|
+
def exists?(klass)
|
|
95
|
+
@entries.any? { _1.klass == klass }
|
|
96
|
+
end
|
|
97
|
+
alias include? exists?
|
|
98
|
+
|
|
99
|
+
# @return [Boolean]
|
|
100
|
+
def empty?
|
|
101
|
+
@entries.empty?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [Chain]
|
|
105
|
+
def clear
|
|
106
|
+
@entries.clear
|
|
107
|
+
self
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Runs the block wrapped in every middleware, passing +args+ to each one's +call+.
|
|
111
|
+
#
|
|
112
|
+
# @return [Object] the outermost middleware's return value, normally the block's
|
|
113
|
+
def invoke(*args, &)
|
|
114
|
+
return yield if empty?
|
|
115
|
+
|
|
116
|
+
traverse(map(&:build), 0, args, &)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def traverse(chain, position, args, &block)
|
|
122
|
+
return yield if position >= chain.size
|
|
123
|
+
|
|
124
|
+
chain[position].call(*args) { traverse(chain, position + 1, args, &block) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def index(klass)
|
|
128
|
+
@entries.index { _1.klass == klass }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def take(klass, args, kwargs)
|
|
132
|
+
position = index(klass)
|
|
133
|
+
position ? @entries.delete_at(position) : Entry.new(klass, args, kwargs)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module Cosmo
|
|
6
|
+
module Middleware
|
|
7
|
+
# Enforces a job class's +limit:+ option: a concurrency slot per key, requeueing the job while every slot is
|
|
8
|
+
# taken, and the +duration+ timeout. Jobs without +limit:+ pass straight through. It runs first in the chain,
|
|
9
|
+
# so a requeued job never reaches the stats or user middleware.
|
|
10
|
+
class Limit
|
|
11
|
+
# @param job [Cosmo::Job]
|
|
12
|
+
# @param data [Hash]
|
|
13
|
+
# @param _message [NATS::Msg]
|
|
14
|
+
# @raise [Job::Requeue] while every concurrency slot is taken
|
|
15
|
+
def call(job, data, _message, &)
|
|
16
|
+
limit = job.class.default_options[:limit]
|
|
17
|
+
return yield unless limit
|
|
18
|
+
|
|
19
|
+
slot = acquire(job.class, data)
|
|
20
|
+
timeout(limit[:duration], &)
|
|
21
|
+
ensure
|
|
22
|
+
Job::Limit.instance.release(slot) if slot
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def acquire(job_class, data)
|
|
28
|
+
options = job_class.concurrency_options
|
|
29
|
+
return unless options
|
|
30
|
+
|
|
31
|
+
key = job_class.concurrency_key(data[:args])
|
|
32
|
+
slot = Job::Limit.instance.acquire(key, jid: data[:jid], limit: options[:limit], duration: options[:duration])
|
|
33
|
+
raise Job::Requeue, options[:retry_in] unless slot
|
|
34
|
+
|
|
35
|
+
slot
|
|
36
|
+
rescue NATS::Error => e
|
|
37
|
+
Logger.error e
|
|
38
|
+
Cosmo.handle_error(e, { source: :limit, class: job_class.name, jid: data[:jid] })
|
|
39
|
+
raise Job::Requeue
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def timeout(duration, &)
|
|
43
|
+
return yield unless duration
|
|
44
|
+
|
|
45
|
+
seconds = duration.to_i
|
|
46
|
+
Timeout.timeout(seconds, Timeout::Error, "execution expired after the #{seconds}s duration limit", &)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -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
|
|
@@ -2,28 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
# rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize
|
|
4
4
|
module Cosmo
|
|
5
|
-
module
|
|
6
|
-
|
|
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
|
|
7
17
|
NAME_PREFIX = "Cosmonats"
|
|
8
18
|
OP_NAME = "queue.cosmonats"
|
|
9
19
|
SPAN_ORIGIN = "auto.queue.cosmonats"
|
|
10
20
|
STATUS_OK = 200
|
|
11
21
|
STATUS_FAIL = 500
|
|
12
22
|
|
|
13
|
-
|
|
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]
|
|
14
30
|
# @param data [Hash]
|
|
15
31
|
# @param message [NATS::Msg]
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
unless ::Sentry.initialized?
|
|
19
|
-
super
|
|
20
|
-
return
|
|
21
|
-
end
|
|
32
|
+
def call(job, data, message)
|
|
33
|
+
return yield unless ::Sentry.initialized?
|
|
22
34
|
|
|
23
35
|
scope = ::Sentry.get_current_scope
|
|
24
|
-
transaction_name = "#{NAME_PREFIX}/#{
|
|
36
|
+
transaction_name = "#{NAME_PREFIX}/#{job.class.name}"
|
|
25
37
|
scope.set_transaction_name(transaction_name, source: :task)
|
|
26
|
-
|
|
27
38
|
transaction = ::Sentry.start_transaction(
|
|
28
39
|
name: scope.transaction_name,
|
|
29
40
|
source: scope.transaction_source,
|
|
@@ -35,29 +46,14 @@ module Cosmo
|
|
|
35
46
|
transaction&.set_data("messaging.message.retry.count", data[:retry] || 0)
|
|
36
47
|
|
|
37
48
|
begin
|
|
38
|
-
|
|
39
|
-
|
|
49
|
+
result = yield
|
|
40
50
|
transaction&.set_http_status(STATUS_OK)
|
|
41
51
|
transaction&.finish
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
e,
|
|
45
|
-
contexts: {
|
|
46
|
-
cosmonats: data.merge(
|
|
47
|
-
nats_stream: message.metadata.stream,
|
|
48
|
-
nats_subject: message.subject,
|
|
49
|
-
timeout_duration: duration
|
|
50
|
-
)
|
|
51
|
-
},
|
|
52
|
-
hint: {
|
|
53
|
-
background: true,
|
|
54
|
-
integration: "cosmonats"
|
|
55
|
-
}
|
|
56
|
-
)
|
|
52
|
+
result
|
|
53
|
+
rescue StandardError
|
|
57
54
|
transaction&.set_http_status(STATUS_FAIL)
|
|
58
55
|
transaction&.finish
|
|
59
|
-
|
|
60
|
-
raise e
|
|
56
|
+
raise
|
|
61
57
|
end
|
|
62
58
|
end
|
|
63
59
|
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
|
@@ -86,9 +86,10 @@ module Cosmo
|
|
|
86
86
|
end
|
|
87
87
|
all_paused = false
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
key = backoff_key(config)
|
|
90
|
+
_, skip_t = consumer_state[key]
|
|
90
91
|
if skip_t && Time.now < skip_t
|
|
91
|
-
Logger.trace "
|
|
92
|
+
Logger.trace "#{key} is empty, backing off"
|
|
92
93
|
next
|
|
93
94
|
end
|
|
94
95
|
all_empty = false
|
|
@@ -96,7 +97,7 @@ module Cosmo
|
|
|
96
97
|
begin
|
|
97
98
|
@pool.post do
|
|
98
99
|
# Re-check, after possibly being blocked on post to thread pool
|
|
99
|
-
_, skip_t = consumer_state[
|
|
100
|
+
_, skip_t = consumer_state[key]
|
|
100
101
|
next if skip_t && Time.now < skip_t
|
|
101
102
|
|
|
102
103
|
timeout = fetch_timeout(config)
|
|
@@ -105,11 +106,11 @@ module Cosmo
|
|
|
105
106
|
messages = fetch(subscription, batch_size: config[:batch_size], timeout:)
|
|
106
107
|
Logger.trace "fetched (#{messages&.size.to_i}) messages"
|
|
107
108
|
if messages&.any?
|
|
108
|
-
consumer_state.delete(
|
|
109
|
+
consumer_state.delete(key)
|
|
109
110
|
track_busy { process(messages, processor) }
|
|
110
111
|
else
|
|
111
112
|
max_backoff = Utils::Duration.parse(ENV.fetch("COSMO_STREAM_EMPTY_BACKOFF_MAX", STREAM_EMPTY_BACKOFF_MAX))
|
|
112
|
-
consumer_state.compute(
|
|
113
|
+
consumer_state.compute(key) do |(count, wake_at)|
|
|
113
114
|
next [count, wake_at] if wake_at && wake_at > started_at
|
|
114
115
|
|
|
115
116
|
count = count.to_i + 1
|
|
@@ -179,6 +180,7 @@ module Cosmo
|
|
|
179
180
|
rescue StandardError => e
|
|
180
181
|
Logger.error "Snap! Error just happened"
|
|
181
182
|
Logger.error "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
|
|
183
|
+
Cosmo.handle_error(e, { source: :fetch })
|
|
182
184
|
|
|
183
185
|
backoff = ENV.fetch("COSMO_STREAMS_FETCH_BACKOFF", 5).to_f
|
|
184
186
|
sleep([timeout, backoff].max) # backoff before retry
|
|
@@ -196,5 +198,9 @@ module Cosmo
|
|
|
196
198
|
def consumer_state
|
|
197
199
|
@consumer_state ||= Concurrent::Map.new
|
|
198
200
|
end
|
|
201
|
+
|
|
202
|
+
def backoff_key(config)
|
|
203
|
+
config[:stream].to_s
|
|
204
|
+
end
|
|
199
205
|
end
|
|
200
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
|
|
@@ -38,8 +38,10 @@ module Cosmo
|
|
|
38
38
|
processor.process(messages)
|
|
39
39
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "done" }
|
|
40
40
|
rescue StandardError => e
|
|
41
|
-
Logger.
|
|
41
|
+
Logger.error e
|
|
42
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 })
|
|
43
45
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
44
46
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
|
|
45
47
|
raise
|
|
@@ -70,6 +72,10 @@ module Cosmo
|
|
|
70
72
|
config.dig(:consumer, :subjects)
|
|
71
73
|
end
|
|
72
74
|
|
|
75
|
+
def backoff_key(config)
|
|
76
|
+
config[:consumer_name] || config[:class].name
|
|
77
|
+
end
|
|
78
|
+
|
|
73
79
|
def fetch_timeout(config)
|
|
74
80
|
timeout = config[:fetch_timeout].to_f
|
|
75
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.rb
CHANGED
data/lib/cosmo/version.rb
CHANGED