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/job/processor.rb
CHANGED
|
@@ -5,27 +5,62 @@ require "timeout"
|
|
|
5
5
|
module Cosmo
|
|
6
6
|
module Job
|
|
7
7
|
class Processor < ::Cosmo::Processor
|
|
8
|
+
# Headers the scheduler consumes rather than forwards. The +X-+ ones address the dispatch
|
|
9
|
+
# itself, and NATS sets +Nats-Scheduler+/+Nats-Schedule-Next+ on a message it fires and
|
|
10
|
+
# rejects a publish that carries them back.
|
|
11
|
+
DISPATCH_HEADERS = %w[X-Stream X-Subject X-Execute-At Nats-Expected-Stream
|
|
12
|
+
Nats-Scheduler Nats-Schedule-Next].freeze
|
|
13
|
+
|
|
14
|
+
# @raise [UnknownJobStreamError] when --streams/--stream or COSMO_JOBS_STREAMS names an unconfigured stream
|
|
15
|
+
def self.validate_options!(options)
|
|
16
|
+
StreamFilter.from(options[:streams]).validate!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Array<String>] job streams pulled from, plus +scheduled+ when dispatching scheduled jobs
|
|
20
|
+
def subscriptions
|
|
21
|
+
names = @consumers.map { |(_, config, _)| config[:stream].to_s }
|
|
22
|
+
names << StreamFilter::SCHEDULED.to_s if scheduler? && scheduled_config
|
|
23
|
+
names
|
|
24
|
+
end
|
|
25
|
+
|
|
8
26
|
private
|
|
9
27
|
|
|
10
28
|
def setup
|
|
29
|
+
filter = StreamFilter.from(@options[:streams]).validate!
|
|
30
|
+
|
|
11
31
|
# Initialize singletons before starting to process messages
|
|
12
|
-
API::Busy.instance
|
|
13
|
-
API::
|
|
32
|
+
API::Stats::Busy.instance
|
|
33
|
+
API::Stats::Totals.instance
|
|
14
34
|
Limit.instance
|
|
15
35
|
|
|
16
36
|
jobs_config = Config.dig(:consumers, :jobs)
|
|
17
37
|
jobs_config&.each do |stream_name, config|
|
|
18
|
-
next if stream_name ==
|
|
38
|
+
next if stream_name == StreamFilter::SCHEDULED # scheduled jobs are handled in schedule_loop
|
|
39
|
+
next unless filter.include?(stream_name)
|
|
19
40
|
|
|
20
41
|
@consumers << subscribe(stream_name, config)
|
|
21
42
|
end
|
|
43
|
+
|
|
44
|
+
log_subscriptions
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def log_subscriptions
|
|
48
|
+
return if @consumers.empty?
|
|
49
|
+
|
|
50
|
+
names = @consumers.map { |(_, config, _)| config[:consumer] }
|
|
51
|
+
names << consumer_name(StreamFilter::SCHEDULED) if scheduler? && scheduled_config
|
|
52
|
+
Logger.info "subscribed: #{names.join(", ")}#{" (scheduler off)" unless scheduler?}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def scheduled_config
|
|
56
|
+
Config.dig(:consumers, :jobs, StreamFilter::SCHEDULED)
|
|
22
57
|
end
|
|
23
58
|
|
|
24
59
|
def schedule_loop # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Metrics/AbcSize
|
|
25
|
-
config =
|
|
60
|
+
config = scheduled_config
|
|
26
61
|
return unless config
|
|
27
62
|
|
|
28
|
-
subscription, = subscribe(
|
|
63
|
+
subscription, = subscribe(StreamFilter::SCHEDULED, config)
|
|
29
64
|
while running?
|
|
30
65
|
break unless running?
|
|
31
66
|
|
|
@@ -33,13 +68,18 @@ module Cosmo
|
|
|
33
68
|
timeout = ENV.fetch("COSMO_JOBS_SCHEDULER_FETCH_TIMEOUT", 5).to_f
|
|
34
69
|
messages = fetch(subscription, batch_size: 100, timeout:)
|
|
35
70
|
messages&.each do |message|
|
|
36
|
-
headers = message.header.except(
|
|
71
|
+
headers = message.header.except(*DISPATCH_HEADERS)
|
|
37
72
|
stream, subject, execute_at = message.header.values_at("X-Stream", "X-Subject", "X-Execute-At")
|
|
38
73
|
headers["Nats-Expected-Stream"] = stream
|
|
74
|
+
scheduler = message.header["Nats-Scheduler"]
|
|
75
|
+
if scheduler
|
|
76
|
+
headers["X-Scheduled-By"] = scheduler
|
|
77
|
+
headers["X-Enqueued-At"] = message.metadata.timestamp.to_f.to_s
|
|
78
|
+
end
|
|
39
79
|
execute_at = execute_at.to_i
|
|
40
80
|
|
|
41
81
|
if now >= execute_at
|
|
42
|
-
client.publish(subject, message.data,
|
|
82
|
+
client.publish(subject, message.data, header: headers)
|
|
43
83
|
message.ack
|
|
44
84
|
else
|
|
45
85
|
message.nak(delay: Config.to_ns(execute_at - now))
|
|
@@ -60,16 +100,11 @@ module Cosmo
|
|
|
60
100
|
message = messages.first
|
|
61
101
|
Logger.debug "received messages #{messages.inspect}"
|
|
62
102
|
data = Utils::Json.parse(message.data)
|
|
63
|
-
unless data
|
|
64
|
-
Logger.error ArgumentError.new("malformed payload")
|
|
65
|
-
move_message(message)
|
|
66
|
-
return
|
|
67
|
-
end
|
|
103
|
+
return reject_message(message, ArgumentError.new("malformed payload")) unless data
|
|
68
104
|
|
|
69
105
|
worker_class = Utils::String.safe_constantize(data[:class])
|
|
70
106
|
unless worker_class
|
|
71
|
-
|
|
72
|
-
move_message(message, data)
|
|
107
|
+
reject_message(message, ArgumentError.new("#{data[:class]} class not found"), data)
|
|
73
108
|
notify_batch(data, success: false)
|
|
74
109
|
return
|
|
75
110
|
end
|
|
@@ -115,9 +150,9 @@ module Cosmo
|
|
|
115
150
|
def build_worker(worker_class, data, message)
|
|
116
151
|
worker_class.new.tap do |worker|
|
|
117
152
|
worker.jid = data[:jid]
|
|
118
|
-
worker.enqueued_at = message
|
|
153
|
+
worker.enqueued_at = enqueued_at(message)
|
|
119
154
|
worker.attempt = message.metadata.num_delivered
|
|
120
|
-
worker.scheduled_by = message
|
|
155
|
+
worker.scheduled_by = scheduled_by(message)
|
|
121
156
|
worker.batch_id = data[:batch_id]
|
|
122
157
|
end
|
|
123
158
|
end
|
|
@@ -154,7 +189,7 @@ module Cosmo
|
|
|
154
189
|
end
|
|
155
190
|
|
|
156
191
|
warn_capped(message, data, capped_at) if capped_at
|
|
157
|
-
data[:dead] ? move_message(message, data) : drop_message(message, data)
|
|
192
|
+
data[:dead] ? move_message(message, data, exception) : drop_message(message, data)
|
|
158
193
|
notify_batch(data, success: false)
|
|
159
194
|
true
|
|
160
195
|
end
|
|
@@ -207,7 +242,7 @@ module Cosmo
|
|
|
207
242
|
config = config.dup
|
|
208
243
|
config[:batch_size] = 1
|
|
209
244
|
config[:stream] = stream_name
|
|
210
|
-
config[:consumer] =
|
|
245
|
+
config[:consumer] = consumer_name(stream_name)
|
|
211
246
|
subscription = client.subscribe(config[:subject], config[:consumer], config.except(:subject, :priority, :stream, :batch_size, :consumer))
|
|
212
247
|
[subscription, config, nil]
|
|
213
248
|
end
|
|
@@ -217,16 +252,44 @@ module Cosmo
|
|
|
217
252
|
Logger.debug "job dropped #{data[:jid]}"
|
|
218
253
|
end
|
|
219
254
|
|
|
220
|
-
|
|
255
|
+
# Logs why a message can't be processed at all and parks it in the DLQ.
|
|
256
|
+
def reject_message(message, error, data = nil)
|
|
257
|
+
Logger.error error
|
|
258
|
+
move_message(message, data, error)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def move_message(message, data = nil, exception = nil)
|
|
221
262
|
klass = data ? Utils::String.underscore(data[:class]) : "default"
|
|
222
263
|
headers = { "X-Stream" => message.metadata.stream, "X-Subject" => message.subject }
|
|
264
|
+
headers.merge!(Failure.headers(exception)) if exception
|
|
223
265
|
Client.instance.publish("jobs.dead.#{klass}", message.data, header: headers)
|
|
224
266
|
message.ack
|
|
225
267
|
Logger.debug "job moved #{data&.dig(:jid)} to DLQ"
|
|
226
268
|
end
|
|
227
269
|
|
|
228
270
|
def scheduler?
|
|
229
|
-
true
|
|
271
|
+
@options.fetch(:scheduler, true)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# +Nats-Scheduler+ on a message NATS fired directly into a job stream, and +X-Scheduled-By+
|
|
275
|
+
# on one the scheduler dispatched onward, which may not carry the reserved header.
|
|
276
|
+
def scheduled_by(message)
|
|
277
|
+
header = message.header or return
|
|
278
|
+
|
|
279
|
+
header["X-Scheduled-By"] || header["Nats-Scheduler"]
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# A cron message is enqueued when NATS fires it, not when the scheduler dispatches it on, so
|
|
283
|
+
# the firing forwards its own timestamp in +X-Enqueued-At+ to survive the re-publish.
|
|
284
|
+
def enqueued_at(message)
|
|
285
|
+
forwarded = message.header&.dig("X-Enqueued-At")
|
|
286
|
+
|
|
287
|
+
forwarded ? Time.at(forwarded.to_f) : message.metadata.timestamp
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Durable and per stream, so every process pulls from the same consumer and shares the work.
|
|
291
|
+
def consumer_name(stream_name)
|
|
292
|
+
"consumer-#{stream_name}"
|
|
230
293
|
end
|
|
231
294
|
|
|
232
295
|
def consumers
|
|
@@ -243,8 +306,8 @@ module Cosmo
|
|
|
243
306
|
end
|
|
244
307
|
|
|
245
308
|
def with_stats(message, &block)
|
|
246
|
-
API::Busy.instance.with(message) do
|
|
247
|
-
API::
|
|
309
|
+
API::Stats::Busy.instance.with(message) do
|
|
310
|
+
API::Stats::Totals.instance.with(&block)
|
|
248
311
|
end
|
|
249
312
|
end
|
|
250
313
|
|
|
@@ -253,15 +316,14 @@ module Cosmo
|
|
|
253
316
|
# @param message [NATS::Msg]
|
|
254
317
|
# @param duration [Float, nil]
|
|
255
318
|
#
|
|
256
|
-
# rubocop:disable Lint/UnusedMethodArgument
|
|
319
|
+
# rubocop:disable-next Lint/UnusedMethodArgument
|
|
257
320
|
def perform_job(job_instance, data:, message:, duration: nil)
|
|
258
321
|
if duration
|
|
259
|
-
Timeout.timeout(duration) { job_instance.perform(*data[:args]) }
|
|
322
|
+
Timeout.timeout(duration, Timeout::Error, "execution expired after the #{duration}s duration limit") { job_instance.perform(*data[:args]) }
|
|
260
323
|
else
|
|
261
324
|
job_instance.perform(*data[:args])
|
|
262
325
|
end
|
|
263
326
|
end
|
|
264
|
-
# rubocop:enable Lint/UnusedMethodArgument
|
|
265
327
|
end
|
|
266
328
|
end
|
|
267
329
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
module Job
|
|
5
|
+
# Which job streams a worker subscribes to: every configured stream, or the subset named by
|
|
6
|
+
# +--streams+/+--stream+, falling back to the +COSMO_JOBS_STREAMS+ environment variable.
|
|
7
|
+
#
|
|
8
|
+
# The scheduled stream is a service stream. Naming it selects nothing, it runs unless the worker
|
|
9
|
+
# was started with +--no-scheduler+.
|
|
10
|
+
class StreamFilter
|
|
11
|
+
SCHEDULED = :scheduled
|
|
12
|
+
ENV_NAME = "COSMO_JOBS_STREAMS"
|
|
13
|
+
|
|
14
|
+
# @param names [Array<String>, nil] stream names from the command line
|
|
15
|
+
# @return [StreamFilter]
|
|
16
|
+
def self.from(names)
|
|
17
|
+
new(names || ENV[ENV_NAME]&.split(","))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param names [Array<String>, nil]
|
|
21
|
+
def initialize(names)
|
|
22
|
+
@names = Array(names).map(&:strip).reject(&:empty?)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Boolean] whether every configured stream is selected
|
|
26
|
+
def all?
|
|
27
|
+
@names.empty?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param stream_name [String, Symbol]
|
|
31
|
+
# @return [Boolean] whether the stream is selected
|
|
32
|
+
def include?(stream_name)
|
|
33
|
+
all? || @names.include?(stream_name.to_s)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [StreamFilter] self, so it can be chained onto {.from}
|
|
37
|
+
# @raise [UnknownJobStreamError] when a name is not configured
|
|
38
|
+
def validate!
|
|
39
|
+
return self if all?
|
|
40
|
+
|
|
41
|
+
unknown = @names - configured - [SCHEDULED.to_s]
|
|
42
|
+
raise UnknownJobStreamError.new(unknown, configured) if unknown.any?
|
|
43
|
+
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def configured
|
|
50
|
+
@configured ||= Config.dig(:consumers, :jobs)&.keys.to_a.map(&:to_s) - [SCHEDULED.to_s]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/cosmo/job.rb
CHANGED
data/lib/cosmo/logger.rb
CHANGED
|
@@ -5,6 +5,8 @@ require "forwardable"
|
|
|
5
5
|
|
|
6
6
|
module Cosmo
|
|
7
7
|
module Logger
|
|
8
|
+
TRACE = -1 # Below DEBUG; opt in with COSMO_LOG_LEVEL=trace for high-frequency polling/loop noise
|
|
9
|
+
|
|
8
10
|
module Context
|
|
9
11
|
KEY = :cosmo_context
|
|
10
12
|
|
|
@@ -45,10 +47,21 @@ module Cosmo
|
|
|
45
47
|
end
|
|
46
48
|
end
|
|
47
49
|
|
|
50
|
+
# Adds TRACE (below DEBUG) on top of stdlib's fixed DEBUG..UNKNOWN severities.
|
|
51
|
+
class Instance < ::Logger
|
|
52
|
+
def trace(progname = nil, &)
|
|
53
|
+
add(TRACE, nil, progname, &)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def format_severity(severity)
|
|
57
|
+
severity == TRACE ? "TRACE" : super
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
48
61
|
class << self
|
|
49
62
|
extend Forwardable
|
|
50
63
|
|
|
51
|
-
delegate %i[info error debug warn fatal] => :instance
|
|
64
|
+
delegate %i[info error debug warn fatal trace] => :instance
|
|
52
65
|
end
|
|
53
66
|
|
|
54
67
|
def self.with(...)
|
|
@@ -60,12 +73,17 @@ module Cosmo
|
|
|
60
73
|
end
|
|
61
74
|
|
|
62
75
|
def self.instance
|
|
63
|
-
@instance ||=
|
|
76
|
+
@instance ||= Instance.new($stdout).tap do |logger|
|
|
64
77
|
logger.formatter = SimpleFormatter.new
|
|
65
|
-
logger.level =
|
|
78
|
+
logger.level = coerce_level(ENV.fetch("COSMO_LOG_LEVEL", "info"))
|
|
66
79
|
end
|
|
67
80
|
end
|
|
68
81
|
|
|
82
|
+
def self.coerce_level(level)
|
|
83
|
+
level.to_s.downcase == "trace" ? TRACE : ::Logger::Severity.coerce(level)
|
|
84
|
+
end
|
|
85
|
+
private_class_method :coerce_level
|
|
86
|
+
|
|
69
87
|
def self.instance=(logger)
|
|
70
88
|
@instance = logger
|
|
71
89
|
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
|
|
@@ -60,14 +81,14 @@ module Cosmo
|
|
|
60
81
|
stream_name = config[:stream].to_s
|
|
61
82
|
ttl = Utils::Duration.parse(ENV.fetch("COSMO_STREAM_PAUSED_RECHECK_TTL", STREAM_PAUSED_RECHECK_TTL))
|
|
62
83
|
if @cache.fetch("#{stream_name}:paused", ttl:) { API::Stream.new(stream_name).paused? }
|
|
63
|
-
Logger.
|
|
84
|
+
Logger.trace "stream #{stream_name} is paused, skipping fetch"
|
|
64
85
|
next
|
|
65
86
|
end
|
|
66
87
|
all_paused = false
|
|
67
88
|
|
|
68
89
|
_, skip_t = consumer_state[stream_name]
|
|
69
90
|
if skip_t && Time.now < skip_t
|
|
70
|
-
Logger.
|
|
91
|
+
Logger.trace "stream #{stream_name} is empty, backing off"
|
|
71
92
|
next
|
|
72
93
|
end
|
|
73
94
|
all_empty = false
|
|
@@ -79,16 +100,19 @@ module Cosmo
|
|
|
79
100
|
next if skip_t && Time.now < skip_t
|
|
80
101
|
|
|
81
102
|
timeout = fetch_timeout(config)
|
|
82
|
-
Logger.
|
|
83
|
-
|
|
84
|
-
|
|
103
|
+
Logger.trace "fetching #{fetch_subjects(config).inspect}, timeout=#{timeout}"
|
|
104
|
+
started_at = Time.now
|
|
105
|
+
messages = fetch(subscription, batch_size: config[:batch_size], timeout:)
|
|
106
|
+
Logger.trace "fetched (#{messages&.size.to_i}) messages"
|
|
85
107
|
if messages&.any?
|
|
86
108
|
consumer_state.delete(stream_name)
|
|
87
|
-
process(messages, processor)
|
|
109
|
+
track_busy { process(messages, processor) }
|
|
88
110
|
else
|
|
89
111
|
max_backoff = Utils::Duration.parse(ENV.fetch("COSMO_STREAM_EMPTY_BACKOFF_MAX", STREAM_EMPTY_BACKOFF_MAX))
|
|
90
|
-
consumer_state.compute(stream_name) do |
|
|
91
|
-
count
|
|
112
|
+
consumer_state.compute(stream_name) do |(count, wake_at)|
|
|
113
|
+
next [count, wake_at] if wake_at && wake_at > started_at
|
|
114
|
+
|
|
115
|
+
count = count.to_i + 1
|
|
92
116
|
backoff = [timeout * (2**(count - 1)), max_backoff].min
|
|
93
117
|
[count, Time.now + backoff]
|
|
94
118
|
end
|
|
@@ -104,14 +128,14 @@ module Cosmo
|
|
|
104
128
|
|
|
105
129
|
if all_paused
|
|
106
130
|
period = Utils::Duration.parse(ENV.fetch("COSMO_STREAMS_PAUSED_IDLE_SLEEP", STREAMS_PAUSED_IDLE_SLEEP))
|
|
107
|
-
Logger.
|
|
131
|
+
Logger.trace "all streams paused, sleep=#{period}"
|
|
108
132
|
sleep(period)
|
|
109
133
|
elsif all_empty
|
|
110
134
|
next_wake = consumer_state.values.filter_map { |_, t| t }.min
|
|
111
135
|
next unless next_wake # entry was deleted concurrently (messages arrived), re-loop immediately
|
|
112
136
|
|
|
113
137
|
remaining = [next_wake - Time.now, 0.01].max
|
|
114
|
-
Logger.
|
|
138
|
+
Logger.trace "all streams empty, sleep=#{remaining}"
|
|
115
139
|
sleep(remaining)
|
|
116
140
|
end
|
|
117
141
|
end
|
|
@@ -129,10 +153,21 @@ module Cosmo
|
|
|
129
153
|
raise NotImplementedError
|
|
130
154
|
end
|
|
131
155
|
|
|
156
|
+
def track_busy
|
|
157
|
+
@busy.increment
|
|
158
|
+
yield
|
|
159
|
+
ensure
|
|
160
|
+
@busy.decrement
|
|
161
|
+
end
|
|
162
|
+
|
|
132
163
|
def running?
|
|
133
164
|
@running.true?
|
|
134
165
|
end
|
|
135
166
|
|
|
167
|
+
def quiet?
|
|
168
|
+
@quiet.true?
|
|
169
|
+
end
|
|
170
|
+
|
|
136
171
|
def scheduler?
|
|
137
172
|
false
|
|
138
173
|
end
|
|
@@ -158,11 +193,6 @@ module Cosmo
|
|
|
158
193
|
Utils::Stopwatch.new
|
|
159
194
|
end
|
|
160
195
|
|
|
161
|
-
def lock(stream_name, &)
|
|
162
|
-
@locks ||= Hash.new { |h, k| h[k] = Mutex.new }
|
|
163
|
-
@locks[stream_name].synchronize(&)
|
|
164
|
-
end
|
|
165
|
-
|
|
166
196
|
def consumer_state
|
|
167
197
|
@consumer_state ||= Concurrent::Map.new
|
|
168
198
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
3
|
+
# rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize
|
|
4
4
|
module Cosmo
|
|
5
5
|
module Sentry
|
|
6
6
|
module JobProcessorMiddleware
|
|
@@ -63,4 +63,3 @@ module Cosmo
|
|
|
63
63
|
end
|
|
64
64
|
end
|
|
65
65
|
end
|
|
66
|
-
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
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
|
data/lib/cosmo/utils/json.rb
CHANGED