cosmonats 0.3.0 → 0.4.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 +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 +10 -5
- data/lib/cosmo/api.rb +1 -0
- data/lib/cosmo/cli.rb +27 -10
- data/lib/cosmo/client.rb +48 -2
- 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 +49 -5
- data/lib/cosmo/job.rb +51 -2
- data/lib/cosmo/processor.rb +1 -1
- data/lib/cosmo/railtie.rb +21 -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 +42 -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 +82 -56
- data/lib/cosmo/web/views/jobs/_scheduled.erb +53 -48
- 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 +1 -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/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 +20 -1
|
@@ -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,67 @@ 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
82
|
instance = worker_class.new
|
|
69
83
|
instance.jid = data[:jid]
|
|
70
|
-
|
|
84
|
+
if duration
|
|
85
|
+
Timeout.timeout(duration) { instance.perform(*data[:args]) }
|
|
86
|
+
else
|
|
87
|
+
instance.perform(*data[:args])
|
|
88
|
+
end
|
|
71
89
|
message.ack
|
|
72
90
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "done" }
|
|
73
91
|
true
|
|
92
|
+
rescue Timeout::Error
|
|
93
|
+
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[timeout]" }
|
|
94
|
+
dropped = handle_failure(message, data)
|
|
95
|
+
false if dropped
|
|
74
96
|
rescue StandardError => e
|
|
75
97
|
Logger.debug e
|
|
76
|
-
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
|
|
98
|
+
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[error]" }
|
|
77
99
|
dropped = handle_failure(message, data)
|
|
78
100
|
false if dropped
|
|
79
101
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
80
|
-
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
|
|
102
|
+
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[exception]" }
|
|
81
103
|
raise
|
|
82
104
|
end
|
|
83
105
|
ensure
|
|
106
|
+
Limit.instance.release(slot) if slot
|
|
84
107
|
Logger.without(:jid)
|
|
85
108
|
Logger.debug "processed message #{message.inspect}"
|
|
86
109
|
end
|
|
87
110
|
|
|
111
|
+
# Tries to acquire a concurrency slot for the job.
|
|
112
|
+
# Returns the slot key (String) on success, or false if all slots are
|
|
113
|
+
# taken (message is NAK'd with a delay equal to +duration+ before returning).
|
|
114
|
+
def acquire_concurrency_slot(worker_class, message, data)
|
|
115
|
+
options = worker_class.concurrency_options
|
|
116
|
+
key = worker_class.concurrency_key(data[:args])
|
|
117
|
+
|
|
118
|
+
slot = Limit.instance.acquire(key, jid: data[:jid], limit: options[:limit], duration: options[:duration])
|
|
119
|
+
return slot if slot
|
|
120
|
+
|
|
121
|
+
message.nak(delay: options[:duration] * Config::NANO)
|
|
122
|
+
Logger.debug "concurrency limit reached for #{data[:class]}, re-queueing back #{data[:jid]}"
|
|
123
|
+
false
|
|
124
|
+
rescue NATS::Error => e
|
|
125
|
+
# Unexpected KV failure (e.g. transient NATS error). NAK immediately so
|
|
126
|
+
# the message is retried rather than stuck in-flight until ack_wait expires.
|
|
127
|
+
Logger.error e
|
|
128
|
+
message.nak
|
|
129
|
+
false
|
|
130
|
+
end
|
|
131
|
+
|
|
88
132
|
def handle_failure(message, data) # rubocop:disable Naming/PredicateMethod
|
|
89
133
|
current_attempt = message.metadata.num_delivered
|
|
90
134
|
max_retries = data[:retry].to_i + 1
|
|
@@ -93,7 +137,7 @@ module Cosmo
|
|
|
93
137
|
# NATS will auto-retry with delay (exponential backoff based on current attempt).
|
|
94
138
|
# When max_deliver is reached, NATS stops redelivering the message and marks it as "max deliveries exceeded".
|
|
95
139
|
# 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) *
|
|
140
|
+
delay_ns = ((current_attempt**4) + 15) * Config::NANO
|
|
97
141
|
message.nak(delay: delay_ns)
|
|
98
142
|
return false
|
|
99
143
|
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
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<% if @schedules.empty? -%>
|
|
2
|
+
<div class="alert alert-success">
|
|
3
|
+
No cron schedules deployed in NATS.<br>
|
|
4
|
+
Add a <code>cron:</code> section to <code>cosmo.yml</code> and run
|
|
5
|
+
<code>cosmo --setup</code> to publish them.
|
|
6
|
+
</div>
|
|
7
|
+
<% else -%>
|
|
8
|
+
<div class="table-container">
|
|
9
|
+
<table>
|
|
10
|
+
<thead>
|
|
11
|
+
<tr>
|
|
12
|
+
<th>Job</th>
|
|
13
|
+
<th>Expression</th>
|
|
14
|
+
<th>Timezone</th>
|
|
15
|
+
<th>Args</th>
|
|
16
|
+
<th>Stream</th>
|
|
17
|
+
<th>Actions</th>
|
|
18
|
+
</tr>
|
|
19
|
+
</thead>
|
|
20
|
+
<tbody>
|
|
21
|
+
<% @schedules.each do |s| -%>
|
|
22
|
+
<tr id="cron-row-<%= h(s[:registry_key].to_s.gsub("/", "-")) %>">
|
|
23
|
+
<td>
|
|
24
|
+
<div class="job-class"><%= h(s[:class].to_s) %></div>
|
|
25
|
+
<% if s[:name] -%>
|
|
26
|
+
<code class="text-muted" style="font-size:0.8em;"><%= h(s[:name].to_s) %></code>
|
|
27
|
+
<% end -%>
|
|
28
|
+
<div style="font-size:0.75em; color:#888; margin-top:2px;">
|
|
29
|
+
<code><%= h(s[:schedule_subject].to_s) %></code>
|
|
30
|
+
</div>
|
|
31
|
+
</td>
|
|
32
|
+
<td><code class="subject-tag"><%= h(s[:schedule].to_s) %></code></td>
|
|
33
|
+
<td><%= h(s[:timezone] || "UTC") %></td>
|
|
34
|
+
<td>
|
|
35
|
+
<% if s[:args]&.any? -%>
|
|
36
|
+
<code><%= h(s[:args].inspect) %></code>
|
|
37
|
+
<% else -%>
|
|
38
|
+
<span class="text-muted">—</span>
|
|
39
|
+
<% end -%>
|
|
40
|
+
</td>
|
|
41
|
+
<td><code><%= h(s[:stream].to_s) %></code></td>
|
|
42
|
+
<td style="white-space:nowrap;">
|
|
43
|
+
<a hx-post="<%= url_for('/crons/run', { subject: u(s[:schedule_subject].to_s) }) %>"
|
|
44
|
+
class="btn btn-success btn-sm"
|
|
45
|
+
title="Dispatch this job now, bypassing the schedule timer">► Run Now</a>
|
|
46
|
+
<a hx-delete="<%= url_for('/crons/delete', { subject: u(s[:schedule_subject].to_s) }) %>"
|
|
47
|
+
hx-target="#crons-table"
|
|
48
|
+
hx-swap="innerHTML"
|
|
49
|
+
hx-confirm="Delete '<%= h(s[:schedule_subject].to_s) %>'? NATS will stop firing it."
|
|
50
|
+
class="btn btn-danger btn-sm"
|
|
51
|
+
title="Purge schedule from NATS">🗑 Delete</a>
|
|
52
|
+
</td>
|
|
53
|
+
</tr>
|
|
54
|
+
<% end -%>
|
|
55
|
+
</tbody>
|
|
56
|
+
</table>
|
|
57
|
+
</div>
|
|
58
|
+
<% end -%>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<section>
|
|
2
|
+
<header><%= render('jobs/_tabs') %></header>
|
|
3
|
+
|
|
4
|
+
<div id="crons-table"
|
|
5
|
+
hx-get="<%= url_for('/crons/_table') %>"
|
|
6
|
+
hx-trigger="load, every 10s"
|
|
7
|
+
hx-swap="innerHTML">
|
|
8
|
+
<div class="alert alert-info">Loading cron schedules…</div>
|
|
9
|
+
</div>
|
|
10
|
+
</section>
|
|
@@ -1,50 +1,55 @@
|
|
|
1
|
-
<div
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
<% else -%>
|
|
9
|
-
<div class="table-container">
|
|
10
|
-
<table>
|
|
11
|
-
<thead>
|
|
12
|
-
<tr>
|
|
13
|
-
<th>Job</th>
|
|
14
|
-
<th>JID[Args]</th>
|
|
15
|
-
<th>Stream</th>
|
|
16
|
-
<th>Worker</th>
|
|
17
|
-
<th>Running for</th>
|
|
18
|
-
</tr>
|
|
19
|
-
</thead>
|
|
20
|
-
<tbody>
|
|
21
|
-
<% @jobs.each do |job| -%>
|
|
22
|
-
<tr>
|
|
23
|
-
<td>
|
|
24
|
-
<div class="job-class">
|
|
25
|
-
<%= h(job.dig(:data, :class).to_s) %>
|
|
26
|
-
</div>
|
|
27
|
-
</td>
|
|
28
|
-
<td>
|
|
29
|
-
<div class="job-id">
|
|
30
|
-
<details>
|
|
31
|
-
<summary>
|
|
32
|
-
<code><%= h(job.dig(:data, :jid)) %></code>
|
|
33
|
-
</summary>
|
|
34
|
-
<code><%= h(JSON.pretty_generate(job.dig(:data, :args))) %></code>
|
|
35
|
-
</details>
|
|
36
|
-
</div>
|
|
37
|
-
</td>
|
|
38
|
-
<td>
|
|
39
|
-
<code><%= h(job[:stream].to_s) %></code>
|
|
40
|
-
</td>
|
|
41
|
-
<td><code style="font-size: var(--font-size-small);"><%= h(job[:worker].to_s) %></code></td>
|
|
42
|
-
<td>
|
|
43
|
-
<%= elapsed(job[:started_at]) %>
|
|
44
|
-
</td>
|
|
45
|
-
</tr>
|
|
46
|
-
<% end -%>
|
|
47
|
-
</tbody>
|
|
48
|
-
</table>
|
|
1
|
+
<div id="busy-poller"
|
|
2
|
+
hx-get="<%= url_for('/jobs/_busy') %>"
|
|
3
|
+
hx-trigger="every 5s"
|
|
4
|
+
hx-swap="outerHTML">
|
|
5
|
+
<div class="pending">
|
|
6
|
+
<span></span>
|
|
7
|
+
<span class="text-muted"><%= @total %> job(s) running</span>
|
|
49
8
|
</div>
|
|
50
|
-
|
|
9
|
+
|
|
10
|
+
<% if @jobs.empty? -%>
|
|
11
|
+
<div class="alert alert-success">No jobs are currently running</div>
|
|
12
|
+
<% else -%>
|
|
13
|
+
<div class="table-container">
|
|
14
|
+
<table>
|
|
15
|
+
<thead>
|
|
16
|
+
<tr>
|
|
17
|
+
<th>Job</th>
|
|
18
|
+
<th>JID[Args]</th>
|
|
19
|
+
<th>Stream</th>
|
|
20
|
+
<th>Worker</th>
|
|
21
|
+
<th>Running for</th>
|
|
22
|
+
</tr>
|
|
23
|
+
</thead>
|
|
24
|
+
<tbody>
|
|
25
|
+
<% @jobs.each do |job| -%>
|
|
26
|
+
<tr>
|
|
27
|
+
<td>
|
|
28
|
+
<div class="job-class">
|
|
29
|
+
<%= h(job.dig(:data, :class).to_s) %>
|
|
30
|
+
</div>
|
|
31
|
+
</td>
|
|
32
|
+
<td>
|
|
33
|
+
<div class="job-id">
|
|
34
|
+
<details>
|
|
35
|
+
<summary>
|
|
36
|
+
<code><%= h(job.dig(:data, :jid)) %></code>
|
|
37
|
+
</summary>
|
|
38
|
+
<code><%= h(JSON.pretty_generate(job.dig(:data, :args))) %></code>
|
|
39
|
+
</details>
|
|
40
|
+
</div>
|
|
41
|
+
</td>
|
|
42
|
+
<td>
|
|
43
|
+
<code><%= h(job[:stream].to_s) %></code>
|
|
44
|
+
</td>
|
|
45
|
+
<td><code style="font-size: var(--font-size-small);"><%= h(job[:worker].to_s) %></code></td>
|
|
46
|
+
<td>
|
|
47
|
+
<%= elapsed(job[:started_at]) %>
|
|
48
|
+
</td>
|
|
49
|
+
</tr>
|
|
50
|
+
<% end -%>
|
|
51
|
+
</tbody>
|
|
52
|
+
</table>
|
|
53
|
+
</div>
|
|
54
|
+
<% end -%>
|
|
55
|
+
</div>
|