cosmonats 0.3.0 → 0.4.1
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 +208 -156
- data/lib/cosmo/active_job/adapter.rb +46 -0
- data/lib/cosmo/active_job/executor.rb +16 -0
- data/lib/cosmo/active_job/options.rb +50 -0
- data/lib/cosmo/active_job.rb +29 -0
- data/lib/cosmo/api/busy.rb +2 -2
- data/lib/cosmo/api/counter.rb +2 -2
- data/lib/cosmo/api/cron/entry.rb +99 -0
- data/lib/cosmo/api/cron.rb +118 -0
- data/lib/cosmo/api/kv.rb +35 -13
- data/lib/cosmo/api/stream.rb +43 -8
- data/lib/cosmo/api.rb +1 -0
- data/lib/cosmo/cli.rb +27 -10
- data/lib/cosmo/client.rb +52 -4
- data/lib/cosmo/config.rb +9 -0
- data/lib/cosmo/job/data.rb +1 -1
- data/lib/cosmo/job/limit.rb +51 -0
- data/lib/cosmo/job/processor.rb +63 -7
- data/lib/cosmo/job.rb +51 -2
- data/lib/cosmo/processor.rb +1 -1
- data/lib/cosmo/railtie.rb +21 -0
- data/lib/cosmo/sentry/auto.rb +6 -0
- data/lib/cosmo/sentry/job_processor_middleware.rb +66 -0
- data/lib/cosmo/stream/processor.rb +2 -2
- data/lib/cosmo/stream.rb +2 -1
- data/lib/cosmo/utils/hash.rb +13 -0
- data/lib/cosmo/utils/overrides.rb +1 -1
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +59 -0
- data/lib/cosmo/web/controllers/crons.rb +41 -0
- data/lib/cosmo/web/controllers/jobs.rb +7 -3
- data/lib/cosmo/web/controllers/streams.rb +1 -1
- data/lib/cosmo/web/helpers/application.rb +4 -0
- data/lib/cosmo/web/views/actions/index.erb +1 -1
- data/lib/cosmo/web/views/crons/_table.erb +58 -0
- data/lib/cosmo/web/views/crons/index.erb +10 -0
- data/lib/cosmo/web/views/jobs/_busy.erb +54 -49
- data/lib/cosmo/web/views/jobs/_dead.erb +70 -65
- data/lib/cosmo/web/views/jobs/_enqueued.erb +85 -56
- data/lib/cosmo/web/views/jobs/_scheduled.erb +53 -48
- data/lib/cosmo/web/views/jobs/_stats.erb +5 -1
- data/lib/cosmo/web/views/jobs/_tabs.erb +6 -0
- data/lib/cosmo/web/views/jobs/busy.erb +8 -6
- data/lib/cosmo/web/views/jobs/dead.erb +6 -5
- data/lib/cosmo/web/views/jobs/enqueued.erb +8 -6
- data/lib/cosmo/web/views/jobs/index.erb +1 -1
- data/lib/cosmo/web/views/jobs/scheduled.erb +6 -5
- data/lib/cosmo/web/views/layout.erb +2 -1
- data/lib/cosmo/web.rb +5 -0
- data/lib/cosmo.rb +1 -0
- data/sig/cosmo/active_job/adapter.rbs +13 -0
- data/sig/cosmo/active_job/executor.rbs +9 -0
- data/sig/cosmo/active_job/options.rbs +14 -0
- data/sig/cosmo/api/cron/entry.rbs +30 -0
- data/sig/cosmo/api/cron.rbs +25 -0
- data/sig/cosmo/api/kv.rbs +4 -6
- data/sig/cosmo/api/stream.rbs +6 -0
- data/sig/cosmo/client.rbs +9 -1
- data/sig/cosmo/job/data.rbs +1 -1
- data/sig/cosmo/job/limit.rbs +18 -0
- data/sig/cosmo/job/processor.rbs +3 -1
- data/sig/cosmo/job.rbs +9 -4
- data/sig/cosmo/railtie.rbs +4 -0
- data/sig/cosmo/utils/hash.rbs +4 -0
- metadata +23 -2
data/lib/cosmo/config.rb
CHANGED
|
@@ -31,11 +31,20 @@ module Cosmo
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
config[:setup]&.each_key do |type|
|
|
34
|
+
next if type == :cron
|
|
35
|
+
|
|
34
36
|
config[:setup][type]&.each_key do |name|
|
|
35
37
|
c = config[:setup][type][name]
|
|
36
38
|
c[:max_age] = c[:max_age].to_i * NANO if c[:max_age]
|
|
37
39
|
c[:duplicate_window] = c[:duplicate_window].to_i * NANO if c[:duplicate_window]
|
|
38
40
|
c[:subjects] = c[:subjects].map { |s| format(s, name: name) } if c[:subjects]
|
|
41
|
+
|
|
42
|
+
next unless type == :jobs # Every jobs stream supports NATS 2.14 message scheduling.
|
|
43
|
+
|
|
44
|
+
c[:allow_msg_schedules] = true
|
|
45
|
+
cron_subject = "#{API::Cron::Entry::SUBJECT_PREFIX}.#{name}.>"
|
|
46
|
+
c[:subjects] = Array(c[:subjects])
|
|
47
|
+
c[:subjects] << cron_subject unless c[:subjects].include?(cron_subject)
|
|
39
48
|
end
|
|
40
49
|
end
|
|
41
50
|
end
|
data/lib/cosmo/job/data.rb
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
module Job
|
|
5
|
+
# Distributed concurrency limiter backed by NATS Key-Value with per-message TTL.
|
|
6
|
+
#
|
|
7
|
+
# Each unit of concurrency is a numbered KV slot:
|
|
8
|
+
# "{concurrency_key}/0", "{concurrency_key}/1", ..., "{concurrency_key}/{limit-1}"
|
|
9
|
+
#
|
|
10
|
+
# Acquiring a slot is a single atomic `set` (CAS with last-revision=0).
|
|
11
|
+
# Only one worker can win a given slot; losers try the next number.
|
|
12
|
+
# When a job finishes the slot is deleted; if the worker crashes NATS
|
|
13
|
+
# expires it automatically via the per-message Nats-TTL header.
|
|
14
|
+
class Limit
|
|
15
|
+
BUCKET = "cosmo_jobs_limits"
|
|
16
|
+
|
|
17
|
+
def self.instance
|
|
18
|
+
@instance ||= new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@kv = API::KV.new(BUCKET, allow_msg_ttl: true)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Try to acquire one of the numbered slots for +key+.
|
|
26
|
+
#
|
|
27
|
+
# @param key [String] concurrency key
|
|
28
|
+
# @param jid [String] stored as the slot value for observability
|
|
29
|
+
# @param limit [Integer] number of slots (0 … limit-1)
|
|
30
|
+
# @param duration [Integer] seconds before the slot is auto-expired by NATS
|
|
31
|
+
# @return [String, nil] the acquired slot key, or nil when all slots are taken
|
|
32
|
+
def acquire(key, jid:, limit:, duration:)
|
|
33
|
+
0.upto(limit - 1) do |i|
|
|
34
|
+
slot = "#{key}/#{i}"
|
|
35
|
+
@kv.set(slot, jid, ttl: duration)
|
|
36
|
+
return slot
|
|
37
|
+
rescue NATS::KeyValue::KeyWrongLastSequenceError
|
|
38
|
+
next # slot is live, try the next one
|
|
39
|
+
end
|
|
40
|
+
nil # all slots occupied
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Release a previously acquired slot.
|
|
44
|
+
def release(slot)
|
|
45
|
+
@kv.delete(slot)
|
|
46
|
+
rescue NATS::Error
|
|
47
|
+
# best effort — slot TTL will reclaim it if delete fails
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/cosmo/job/processor.rb
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
3
5
|
module Cosmo
|
|
4
6
|
module Job
|
|
5
7
|
class Processor < ::Cosmo::Processor
|
|
6
8
|
private
|
|
7
9
|
|
|
8
10
|
def setup
|
|
11
|
+
# Initialize singletons before starting to process messages
|
|
12
|
+
API::Busy.instance
|
|
13
|
+
API::Counter.instance
|
|
14
|
+
Limit.instance
|
|
15
|
+
|
|
9
16
|
jobs_config = Config.dig(:consumers, :jobs)
|
|
10
17
|
jobs_config&.each do |stream_name, config|
|
|
11
18
|
next if stream_name == :scheduled # scheduled jobs are handled in schedule_loop
|
|
@@ -44,7 +51,7 @@ module Cosmo
|
|
|
44
51
|
end
|
|
45
52
|
end
|
|
46
53
|
|
|
47
|
-
def process(messages, _) # rubocop:disable Metrics/MethodLength, Metrics/
|
|
54
|
+
def process(messages, _) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
48
55
|
message = messages.first
|
|
49
56
|
Logger.debug "received messages #{messages.inspect}"
|
|
50
57
|
data = Utils::Json.parse(message.data)
|
|
@@ -61,30 +68,64 @@ module Cosmo
|
|
|
61
68
|
return
|
|
62
69
|
end
|
|
63
70
|
|
|
71
|
+
if worker_class.limits_concurrency?
|
|
72
|
+
slot = acquire_concurrency_slot(worker_class, message, data)
|
|
73
|
+
return if slot == false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
duration = worker_class.default_options[:limit]&.dig(:duration)&.to_i
|
|
77
|
+
|
|
64
78
|
with_stats(message) do
|
|
65
79
|
sw = stopwatch
|
|
66
80
|
Logger.with(jid: data[:jid])
|
|
67
81
|
Logger.info "start"
|
|
68
|
-
|
|
69
|
-
instance.jid = data[:jid]
|
|
70
|
-
instance
|
|
82
|
+
|
|
83
|
+
instance = worker_class.new.tap { |w| w.jid = data[:jid] }
|
|
84
|
+
perform_job(instance, data: data, message: message, duration: duration)
|
|
85
|
+
|
|
71
86
|
message.ack
|
|
72
87
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "done" }
|
|
73
88
|
true
|
|
89
|
+
rescue Timeout::Error
|
|
90
|
+
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[timeout]" }
|
|
91
|
+
dropped = handle_failure(message, data)
|
|
92
|
+
false if dropped
|
|
74
93
|
rescue StandardError => e
|
|
75
94
|
Logger.debug e
|
|
76
|
-
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
|
|
95
|
+
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[error]" }
|
|
77
96
|
dropped = handle_failure(message, data)
|
|
78
97
|
false if dropped
|
|
79
98
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
80
|
-
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
|
|
99
|
+
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[exception]" }
|
|
81
100
|
raise
|
|
82
101
|
end
|
|
83
102
|
ensure
|
|
103
|
+
Limit.instance.release(slot) if slot
|
|
84
104
|
Logger.without(:jid)
|
|
85
105
|
Logger.debug "processed message #{message.inspect}"
|
|
86
106
|
end
|
|
87
107
|
|
|
108
|
+
# Tries to acquire a concurrency slot for the job.
|
|
109
|
+
# Returns the slot key (String) on success, or false if all slots are
|
|
110
|
+
# taken (message is NAK'd with a delay equal to +duration+ before returning).
|
|
111
|
+
def acquire_concurrency_slot(worker_class, message, data)
|
|
112
|
+
options = worker_class.concurrency_options
|
|
113
|
+
key = worker_class.concurrency_key(data[:args])
|
|
114
|
+
|
|
115
|
+
slot = Limit.instance.acquire(key, jid: data[:jid], limit: options[:limit], duration: options[:duration])
|
|
116
|
+
return slot if slot
|
|
117
|
+
|
|
118
|
+
message.nak(delay: options[:duration] * Config::NANO)
|
|
119
|
+
Logger.debug "concurrency limit reached for #{data[:class]}, re-queueing back #{data[:jid]}"
|
|
120
|
+
false
|
|
121
|
+
rescue NATS::Error => e
|
|
122
|
+
# Unexpected KV failure (e.g. transient NATS error). NAK immediately so
|
|
123
|
+
# the message is retried rather than stuck in-flight until ack_wait expires.
|
|
124
|
+
Logger.error e
|
|
125
|
+
message.nak
|
|
126
|
+
false
|
|
127
|
+
end
|
|
128
|
+
|
|
88
129
|
def handle_failure(message, data) # rubocop:disable Naming/PredicateMethod
|
|
89
130
|
current_attempt = message.metadata.num_delivered
|
|
90
131
|
max_retries = data[:retry].to_i + 1
|
|
@@ -93,7 +134,7 @@ module Cosmo
|
|
|
93
134
|
# NATS will auto-retry with delay (exponential backoff based on current attempt).
|
|
94
135
|
# When max_deliver is reached, NATS stops redelivering the message and marks it as "max deliveries exceeded".
|
|
95
136
|
# The message is effectively abandoned by NATS — it stays in the stream (consuming a slot) but will never be delivered again to that consumer.
|
|
96
|
-
delay_ns = ((current_attempt**4) + 15) *
|
|
137
|
+
delay_ns = ((current_attempt**4) + 15) * Config::NANO
|
|
97
138
|
message.nak(delay: delay_ns)
|
|
98
139
|
return false
|
|
99
140
|
end
|
|
@@ -146,6 +187,21 @@ module Cosmo
|
|
|
146
187
|
API::Counter.instance.with(&block)
|
|
147
188
|
end
|
|
148
189
|
end
|
|
190
|
+
|
|
191
|
+
# @param job_instance [Cosmo::Job]
|
|
192
|
+
# @param data [Hash]
|
|
193
|
+
# @param message [NATS::Msg]
|
|
194
|
+
# @param duration [Float, nil]
|
|
195
|
+
#
|
|
196
|
+
# rubocop:disable Lint/UnusedMethodArgument
|
|
197
|
+
def perform_job(job_instance, data:, message:, duration: nil)
|
|
198
|
+
if duration
|
|
199
|
+
Timeout.timeout(duration) { job_instance.perform(*data[:args]) }
|
|
200
|
+
else
|
|
201
|
+
job_instance.perform(*data[:args])
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
# rubocop:enable Lint/UnusedMethodArgument
|
|
149
205
|
end
|
|
150
206
|
end
|
|
151
207
|
end
|
data/lib/cosmo/job.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "cosmo/job/data"
|
|
4
|
+
require "cosmo/job/limit"
|
|
4
5
|
require "cosmo/job/processor"
|
|
5
6
|
|
|
6
7
|
module Cosmo
|
|
@@ -10,8 +11,56 @@ module Cosmo
|
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
module ClassMethods
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
# @option config [Symbol] :stream NATS stream to publish to (default: :default)
|
|
15
|
+
# @option config [Integer] :retry max delivery attempts before giving up (default: 3)
|
|
16
|
+
# @option config [Boolean] :dead move to dead-letter stream after retries exhausted (default: true)
|
|
17
|
+
# @option config [Hash] :limit execution limits:
|
|
18
|
+
#
|
|
19
|
+
# limit: { duration: 30 }
|
|
20
|
+
# limit: { duration: 30, concurrency: 3 }
|
|
21
|
+
# limit: { duration: 30, concurrency: { to: 3, key: ->(id) { id } } }
|
|
22
|
+
#
|
|
23
|
+
# @option config [Integer] :"limit[:duration]" hard execution timeout in seconds. The job thread is
|
|
24
|
+
# killed after this many seconds and counts as a failed attempt (retried with exponential backoff,
|
|
25
|
+
# moved to DLQ after retries exhausted).
|
|
26
|
+
# @option config [Integer, Hash] :"limit[:concurrency]" caps how many instances run at once across all
|
|
27
|
+
# workers. Jobs that cannot acquire a slot are NAK'd with a delay equal to +duration+ so they are not
|
|
28
|
+
# re-delivered until the slot is guaranteed free. Requires +duration+.
|
|
29
|
+
# Pass an Integer for a class-wide cap, or <tt>{ to: N, key: ->(args) {} }</tt> to scope per key.
|
|
30
|
+
def options(**config)
|
|
31
|
+
if config[:limit] && config.dig(:limit, :concurrency) && !config.dig(:limit, :duration).to_i.positive?
|
|
32
|
+
raise ArgumentError, "limit: duration is required when concurrency is set"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
default_options.merge!(config)
|
|
36
|
+
end
|
|
37
|
+
alias cosmo_options options
|
|
38
|
+
|
|
39
|
+
def limits_concurrency?
|
|
40
|
+
!!concurrency_options
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns a normalized concurrency config hash, or +nil+ when not configured.
|
|
44
|
+
# Always contains +:limit+, +:key+, and +:duration+.
|
|
45
|
+
def concurrency_options
|
|
46
|
+
value = default_options.dig(:limit, :concurrency)
|
|
47
|
+
duration = default_options.dig(:limit, :duration).to_i
|
|
48
|
+
return unless value
|
|
49
|
+
|
|
50
|
+
case value
|
|
51
|
+
when Integer then { limit: value, key: nil, duration: duration }
|
|
52
|
+
when Hash then { limit: value.fetch(:to), key: value[:key], duration: duration }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Derive the fully-scoped concurrency key for a given args array.
|
|
57
|
+
def concurrency_key(args)
|
|
58
|
+
config = concurrency_options
|
|
59
|
+
return unless config
|
|
60
|
+
|
|
61
|
+
base = Utils::String.underscore(name)
|
|
62
|
+
suffix = config[:key]&.call(*args)
|
|
63
|
+
suffix ? "#{base}/#{suffix}" : base
|
|
15
64
|
end
|
|
16
65
|
|
|
17
66
|
def perform(*args, async: true, **options)
|
data/lib/cosmo/processor.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Cosmo
|
|
4
|
-
class Processor
|
|
4
|
+
class Processor
|
|
5
5
|
STREAM_PAUSED_RECHECK_TTL = 5.0 # Seconds a stream's paused state is cached before re-checking (override via COSMO_STREAM_PAUSED_RECHECK_TTL)
|
|
6
6
|
STREAMS_PAUSED_IDLE_SLEEP = 1.0 # Seconds 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 = 5.0 # Max seconds to sleep between empty fetches (override via COSMO_STREAM_EMPTY_BACKOFF_MAX)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
# Rails Railtie — loaded automatically when cosmonats is required inside a
|
|
5
|
+
# Rails application. Ensures the ActiveJob adapter constant is registered
|
|
6
|
+
# before Rails tries to resolve it and autoloads +config/cosmo.yml+ when
|
|
7
|
+
# the file is present.
|
|
8
|
+
class Railtie < ::Rails::Railtie
|
|
9
|
+
# Make Cosmo::ActiveJobAdapter::Adapter available under the conventional
|
|
10
|
+
# ActiveJob namespace so :cosmonats resolves without any extra requires.
|
|
11
|
+
initializer "cosmo.active_job_adapter", before: :run_prepare_callbacks do
|
|
12
|
+
require "cosmo/active_job"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Autoload config/cosmo.yml when it exists and no config has been loaded yet.
|
|
16
|
+
initializer "cosmo.load_config", after: "cosmo.active_job_adapter" do |app|
|
|
17
|
+
config_path = app.root.join("config", "cosmo.yml")
|
|
18
|
+
Config.load(config_path.to_s) if config_path.exist? && Config.instance.none?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
4
|
+
module Cosmo
|
|
5
|
+
module Sentry
|
|
6
|
+
module JobProcessorMiddleware
|
|
7
|
+
NAME_PREFIX = "Cosmonats"
|
|
8
|
+
OP_NAME = "queue.cosmonats"
|
|
9
|
+
SPAN_ORIGIN = "auto.queue.cosmonats"
|
|
10
|
+
STATUS_OK = 200
|
|
11
|
+
STATUS_FAIL = 500
|
|
12
|
+
|
|
13
|
+
# @param job_instance [Cosmo::Job]
|
|
14
|
+
# @param data [Hash]
|
|
15
|
+
# @param message [NATS::Msg]
|
|
16
|
+
# @param duration [Float, nil]
|
|
17
|
+
def perform_job(job_instance, data:, message:, duration: nil)
|
|
18
|
+
unless ::Sentry.initialized?
|
|
19
|
+
super
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
scope = ::Sentry.get_current_scope
|
|
24
|
+
transaction_name = "#{NAME_PREFIX}/#{job_instance.class.name}"
|
|
25
|
+
scope.set_transaction_name(transaction_name, source: :task)
|
|
26
|
+
|
|
27
|
+
transaction = ::Sentry.start_transaction(
|
|
28
|
+
name: scope.transaction_name,
|
|
29
|
+
source: scope.transaction_source,
|
|
30
|
+
op: OP_NAME,
|
|
31
|
+
origin: SPAN_ORIGIN
|
|
32
|
+
)
|
|
33
|
+
transaction&.set_data("messaging.message.id", data[:jid])
|
|
34
|
+
transaction&.set_data("messaging.destination.name", "#{message.metadata.stream}:#{message.subject}")
|
|
35
|
+
transaction&.set_data("messaging.message.retry.count", data[:retry] || 0)
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
super
|
|
39
|
+
|
|
40
|
+
transaction&.set_http_status(STATUS_OK)
|
|
41
|
+
transaction&.finish
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
::Sentry.capture_exception(
|
|
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
|
+
)
|
|
57
|
+
transaction&.set_http_status(STATUS_FAIL)
|
|
58
|
+
transaction&.finish
|
|
59
|
+
|
|
60
|
+
raise e
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
|
@@ -17,7 +17,7 @@ module Cosmo
|
|
|
17
17
|
@configs.each { @consumers << subscribe(nil, _1) }
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def process(messages, processor) # rubocop:disable Metrics/AbcSize
|
|
20
|
+
def process(messages, processor) # rubocop:disable Metrics/AbcSize
|
|
21
21
|
metadata = messages.last.metadata
|
|
22
22
|
serializer = processor.class.default_options.dig(:publisher, :serializer)
|
|
23
23
|
messages = messages.map { Message.new(_1, serializer:) }
|
|
@@ -49,7 +49,7 @@ module Cosmo
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def dynamic_config
|
|
52
|
-
Config.internal[:streams]
|
|
52
|
+
Config.internal[:streams]&.map { _1.default_options.merge(class: _1) }.to_a
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def subscribe(_stream_name, config)
|
data/lib/cosmo/stream.rb
CHANGED
|
@@ -12,10 +12,11 @@ module Cosmo
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
module ClassMethods
|
|
15
|
-
def options(stream: nil, consumer_name: nil, batch_size: nil, fetch_timeout: nil, start_position: nil, consumer: nil, publisher: nil)
|
|
15
|
+
def options(stream: nil, consumer_name: nil, batch_size: nil, fetch_timeout: nil, start_position: nil, consumer: nil, publisher: nil)
|
|
16
16
|
register
|
|
17
17
|
default_options.merge!({ stream:, consumer_name:, batch_size:, fetch_timeout:, start_position:, consumer:, publisher: }.compact)
|
|
18
18
|
end
|
|
19
|
+
alias cosmo_options options
|
|
19
20
|
|
|
20
21
|
def publish(data, subject: nil, **options)
|
|
21
22
|
stream = default_options[:stream]
|
data/lib/cosmo/utils/hash.rb
CHANGED
|
@@ -23,6 +23,19 @@ module Cosmo
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
def stringify_keys(obj)
|
|
27
|
+
case obj
|
|
28
|
+
when ::Hash
|
|
29
|
+
obj.each_with_object({}) do |(key, value), result|
|
|
30
|
+
result[key.to_s] = stringify_keys(value)
|
|
31
|
+
end
|
|
32
|
+
when ::Array
|
|
33
|
+
obj.map { |v| stringify_keys(v) }
|
|
34
|
+
else
|
|
35
|
+
obj
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
26
39
|
# deep set
|
|
27
40
|
def set(hash, *keys, value)
|
|
28
41
|
last_key = keys.pop
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
Cosmo::Utils::Warnings.silence do
|
|
4
|
-
members = NATS::JetStream::API::StreamConfig.members + [
|
|
4
|
+
members = NATS::JetStream::API::StreamConfig.members + %i[allow_msg_counter allow_msg_schedules]
|
|
5
5
|
NATS::JetStream::API::StreamConfig = Struct.new(*members, keyword_init: true) do
|
|
6
6
|
def initialize(opts = {})
|
|
7
7
|
rem = opts.keys - members
|
data/lib/cosmo/version.rb
CHANGED
|
@@ -292,6 +292,34 @@ section > header {
|
|
|
292
292
|
z-index: auto;
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
/* ── Section tabs ───────────────────────────────────────────────────────── */
|
|
296
|
+
.tabs {
|
|
297
|
+
display: flex;
|
|
298
|
+
gap: 0;
|
|
299
|
+
border-bottom: 2px solid var(--color-border);
|
|
300
|
+
margin-bottom: var(--space-3x);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.tabs a {
|
|
304
|
+
padding: var(--space) var(--space-3x);
|
|
305
|
+
text-decoration: none;
|
|
306
|
+
color: var(--color-text-light);
|
|
307
|
+
border-bottom: 2px solid transparent;
|
|
308
|
+
margin-bottom: -2px;
|
|
309
|
+
font-weight: 500;
|
|
310
|
+
transition: color 0.15s, border-color 0.15s;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.tabs a:hover {
|
|
314
|
+
color: var(--color-text);
|
|
315
|
+
border-bottom-color: var(--color-border);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.tabs a.active {
|
|
319
|
+
color: var(--color-primary);
|
|
320
|
+
border-bottom-color: var(--color-primary);
|
|
321
|
+
}
|
|
322
|
+
|
|
295
323
|
section .nav {
|
|
296
324
|
display: flex;
|
|
297
325
|
gap: var(--space);
|
|
@@ -448,6 +476,20 @@ time {
|
|
|
448
476
|
.stream-link { color: var(--color-primary); font-weight: 600; }
|
|
449
477
|
.stream-link:hover { text-decoration: underline; }
|
|
450
478
|
|
|
479
|
+
/* ── Pagination ─────────────────────────────────────────────────────────── */
|
|
480
|
+
.pagination {
|
|
481
|
+
display: flex;
|
|
482
|
+
align-items: center;
|
|
483
|
+
justify-content: center;
|
|
484
|
+
gap: var(--space);
|
|
485
|
+
padding: var(--space-2x) 0 var(--space);
|
|
486
|
+
}
|
|
487
|
+
.btn-disabled {
|
|
488
|
+
opacity: 0.4;
|
|
489
|
+
cursor: default;
|
|
490
|
+
pointer-events: none;
|
|
491
|
+
}
|
|
492
|
+
|
|
451
493
|
/* ── Actions ───────────────────────────────────────────────────────────── */
|
|
452
494
|
.actions-form { display: flex; flex-direction: column; gap: var(--space-1-2); }
|
|
453
495
|
|
|
@@ -467,6 +509,23 @@ details code { display: block; max-width: 400px; white-space: pre-wrap; }
|
|
|
467
509
|
.htmx-request .htmx-indicator,
|
|
468
510
|
.htmx-request.htmx-indicator { opacity: 1; }
|
|
469
511
|
|
|
512
|
+
#global-spinner {
|
|
513
|
+
position: fixed;
|
|
514
|
+
top: var(--space-2x);
|
|
515
|
+
right: var(--space-2x);
|
|
516
|
+
width: 24px;
|
|
517
|
+
height: 24px;
|
|
518
|
+
border: 3px solid var(--color-border);
|
|
519
|
+
border-top-color: var(--color-primary);
|
|
520
|
+
border-radius: 50%;
|
|
521
|
+
animation: spin 0.6s linear infinite;
|
|
522
|
+
z-index: 100;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
@keyframes spin {
|
|
526
|
+
to { transform: rotate(360deg); }
|
|
527
|
+
}
|
|
528
|
+
|
|
470
529
|
/* ── Responsive ────────────────────────────────────────────────────────── */
|
|
471
530
|
@media (max-width: 768px) {
|
|
472
531
|
.nav { flex-wrap: wrap; }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cosmo/web/controllers/application"
|
|
4
|
+
|
|
5
|
+
module Cosmo
|
|
6
|
+
class Web
|
|
7
|
+
module Controllers
|
|
8
|
+
class Crons < Application
|
|
9
|
+
def index
|
|
10
|
+
content_for :title, "Crons"
|
|
11
|
+
ok render("crons/index", layout: true)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def _table
|
|
15
|
+
ok render("crons/_table", { schedules: cron.all })
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Dispatch the job immediately, bypassing the schedule timer.
|
|
19
|
+
# Expects params["subject"] = the schedule subject stored in NATS.
|
|
20
|
+
def run_now
|
|
21
|
+
subject = Rack::Utils.unescape(params["subject"].to_s)
|
|
22
|
+
cron.run_now!(subject)
|
|
23
|
+
ok
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Purge the schedule from NATS so it stops firing.
|
|
27
|
+
def delete
|
|
28
|
+
subject = Rack::Utils.unescape(params["subject"].to_s)
|
|
29
|
+
cron.delete!(subject)
|
|
30
|
+
_table
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def cron
|
|
36
|
+
@cron ||= API::Cron.instance
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -72,12 +72,16 @@ module Cosmo
|
|
|
72
72
|
ok render("jobs/_busy", { jobs: jobs, total: API::Busy.instance.size })
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
-
def _enqueued
|
|
75
|
+
def _enqueued # rubocop:disable Metrics/AbcSize
|
|
76
76
|
stream_name, stream_names = streams
|
|
77
|
+
limit = (params["limit"] || API::Stream::LIMIT).to_i
|
|
78
|
+
page = [params["page"].to_i, 1].max
|
|
77
79
|
stream = API::Stream.new(stream_name)
|
|
78
|
-
|
|
80
|
+
total = stream.total
|
|
81
|
+
jobs = stream.messages(page:, limit:)
|
|
82
|
+
total_pages = (total.to_f / limit).ceil
|
|
79
83
|
|
|
80
|
-
ok render("jobs/_enqueued", { jobs:, total
|
|
84
|
+
ok render("jobs/_enqueued", { jobs:, total:, stream_name:, stream_names:, page:, limit:, total_pages: })
|
|
81
85
|
end
|
|
82
86
|
|
|
83
87
|
def _stats
|
|
@@ -40,7 +40,7 @@ module Cosmo
|
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def _table
|
|
43
|
-
streams = API::Stream.all.map { row_locals(_1) }
|
|
43
|
+
streams = API::Stream.all.reject { |s| s.name.start_with?("KV_") || s.name.start_with?("_cosmo") }.map { row_locals(_1) }
|
|
44
44
|
ok render("streams/_table", { streams: streams })
|
|
45
45
|
end
|
|
46
46
|
|