cosmonats 0.2.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 +300 -187
- 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 +36 -14
- data/lib/cosmo/api/stream.rb +27 -9
- data/lib/cosmo/api.rb +1 -0
- data/lib/cosmo/cli.rb +27 -9
- data/lib/cosmo/client.rb +75 -5
- data/lib/cosmo/config.rb +14 -32
- data/lib/cosmo/engine.rb +1 -1
- data/lib/cosmo/job/data.rb +1 -1
- data/lib/cosmo/job/limit.rb +51 -0
- data/lib/cosmo/job/processor.rb +82 -63
- data/lib/cosmo/job.rb +51 -2
- data/lib/cosmo/logger.rb +4 -1
- data/lib/cosmo/processor.rb +108 -0
- data/lib/cosmo/railtie.rb +21 -0
- data/lib/cosmo/stream/processor.rb +24 -60
- data/lib/cosmo/stream.rb +4 -3
- data/lib/cosmo/utils/hash.rb +13 -24
- data/lib/cosmo/utils/overrides.rb +1 -1
- data/lib/cosmo/utils/ttl_cache.rb +44 -0
- data/lib/cosmo/utils.rb +1 -0
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +88 -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 +36 -10
- data/lib/cosmo/web/helpers/application.rb +17 -2
- 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/views/streams/_info.erb +3 -0
- data/lib/cosmo/web/views/streams/_pause_banner.erb +17 -0
- data/lib/cosmo/web/views/streams/_stream_row.erb +42 -0
- data/lib/cosmo/web/views/streams/_table.erb +4 -21
- data/lib/cosmo/web.rb +7 -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 +7 -1
- data/sig/cosmo/client.rbs +20 -4
- data/sig/cosmo/config.rbs +3 -15
- data/sig/cosmo/job/data.rbs +1 -1
- data/sig/cosmo/job/limit.rbs +18 -0
- data/sig/cosmo/job/processor.rbs +19 -9
- data/sig/cosmo/job.rbs +9 -4
- data/sig/cosmo/processor.rbs +26 -0
- data/sig/cosmo/railtie.rbs +4 -0
- data/sig/cosmo/stream/processor.rbs +4 -10
- data/sig/cosmo/utils/hash.rbs +4 -8
- data/sig/cosmo/utils/ttl_cache.rbs +20 -0
- metadata +25 -3
- data/lib/cosmo/defaults.yml +0 -70
data/lib/cosmo/processor.rb
CHANGED
|
@@ -2,15 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
module Cosmo
|
|
4
4
|
class Processor
|
|
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
|
+
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
|
+
STREAM_EMPTY_BACKOFF_MAX = 5.0 # Max seconds to sleep between empty fetches (override via COSMO_STREAM_EMPTY_BACKOFF_MAX)
|
|
8
|
+
|
|
5
9
|
def self.run(...)
|
|
6
10
|
new(...).tap(&:run)
|
|
7
11
|
end
|
|
8
12
|
|
|
13
|
+
attr_reader :consumers
|
|
14
|
+
|
|
9
15
|
def initialize(pool, running, options)
|
|
10
16
|
@pool = pool
|
|
11
17
|
@running = running
|
|
12
18
|
@options = options
|
|
19
|
+
@threads = []
|
|
13
20
|
@consumers = []
|
|
21
|
+
@cache = Utils::TTLCache.new
|
|
14
22
|
end
|
|
15
23
|
|
|
16
24
|
def run
|
|
@@ -21,9 +29,95 @@ module Cosmo
|
|
|
21
29
|
run_loop
|
|
22
30
|
end
|
|
23
31
|
|
|
32
|
+
def stop(timeout = Config[:timeout])
|
|
33
|
+
@running.make_false
|
|
34
|
+
@pool.shutdown
|
|
35
|
+
@consumers.each { |(s, _)| s.unsubscribe rescue nil }
|
|
36
|
+
@pool.wait_for_termination(timeout)
|
|
37
|
+
@threads.compact.each { _1.join(timeout) || _1.kill }
|
|
38
|
+
@consumers.clear
|
|
39
|
+
@threads.clear
|
|
40
|
+
end
|
|
41
|
+
|
|
24
42
|
private
|
|
25
43
|
|
|
26
44
|
def run_loop
|
|
45
|
+
@threads << Thread.new { work_loop }
|
|
46
|
+
@threads << Thread.new { schedule_loop } if scheduler?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def work_loop # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
50
|
+
shutdown = false
|
|
51
|
+
|
|
52
|
+
while running?
|
|
53
|
+
break if shutdown
|
|
54
|
+
|
|
55
|
+
all_empty = true # every stream is empty
|
|
56
|
+
all_paused = true # every stream is paused
|
|
57
|
+
consumers.each do |(subscription, config, processor)| # rubocop:disable Metrics/BlockLength
|
|
58
|
+
break unless running?
|
|
59
|
+
|
|
60
|
+
stream_name = config[:stream].to_s
|
|
61
|
+
ttl = ENV.fetch("COSMO_STREAM_PAUSED_RECHECK_TTL", STREAM_PAUSED_RECHECK_TTL).to_f
|
|
62
|
+
if @cache.fetch(stream_name, ttl:) { API::Stream.new(stream_name).paused? }
|
|
63
|
+
Logger.debug "stream #{stream_name} is paused, skipping fetch"
|
|
64
|
+
next
|
|
65
|
+
end
|
|
66
|
+
all_paused = false
|
|
67
|
+
|
|
68
|
+
_, skip_t = consumer_state[stream_name]
|
|
69
|
+
if skip_t && Time.now < skip_t
|
|
70
|
+
Logger.debug "stream #{stream_name} is empty, backing off"
|
|
71
|
+
next
|
|
72
|
+
end
|
|
73
|
+
all_empty = false
|
|
74
|
+
|
|
75
|
+
begin
|
|
76
|
+
@pool.post do
|
|
77
|
+
# Re-check, after possibly being blocked on post to thread pool
|
|
78
|
+
_, skip_t = consumer_state[stream_name]
|
|
79
|
+
next if skip_t && Time.now < skip_t
|
|
80
|
+
|
|
81
|
+
timeout = fetch_timeout(config)
|
|
82
|
+
Logger.debug "fetching #{fetch_subjects(config).inspect}, timeout=#{timeout}"
|
|
83
|
+
messages = lock(stream_name) { fetch(subscription, batch_size: config[:batch_size], timeout:) }
|
|
84
|
+
Logger.debug "fetched (#{messages&.size.to_i}) messages"
|
|
85
|
+
if messages&.any?
|
|
86
|
+
consumer_state.delete(stream_name)
|
|
87
|
+
process(messages, processor)
|
|
88
|
+
else
|
|
89
|
+
max_backoff = ENV.fetch("COSMO_STREAM_EMPTY_BACKOFF_MAX", STREAM_EMPTY_BACKOFF_MAX).to_f
|
|
90
|
+
consumer_state.compute(stream_name) do |current|
|
|
91
|
+
count = (current&.first || 0) + 1
|
|
92
|
+
backoff = [timeout * (2**(count - 1)), max_backoff].min
|
|
93
|
+
[count, Time.now + backoff]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
rescue Concurrent::RejectedExecutionError
|
|
98
|
+
shutdown = true
|
|
99
|
+
break # pool doesn't accept new jobs, we are shutting down
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
break unless running?
|
|
104
|
+
|
|
105
|
+
if all_paused
|
|
106
|
+
period = ENV.fetch("COSMO_STREAMS_PAUSED_IDLE_SLEEP", STREAMS_PAUSED_IDLE_SLEEP).to_f
|
|
107
|
+
Logger.debug "all streams paused, sleep=#{period}"
|
|
108
|
+
sleep(period)
|
|
109
|
+
elsif all_empty
|
|
110
|
+
next_wake = consumer_state.values.filter_map { |_, t| t }.min
|
|
111
|
+
next unless next_wake # entry was deleted concurrently (messages arrived), re-loop immediately
|
|
112
|
+
|
|
113
|
+
remaining = [next_wake - Time.now, 0.01].max
|
|
114
|
+
Logger.debug "all streams empty, sleep=#{remaining}"
|
|
115
|
+
sleep(remaining)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def schedule_loop
|
|
27
121
|
raise NotImplementedError
|
|
28
122
|
end
|
|
29
123
|
|
|
@@ -39,6 +133,10 @@ module Cosmo
|
|
|
39
133
|
@running.true?
|
|
40
134
|
end
|
|
41
135
|
|
|
136
|
+
def scheduler?
|
|
137
|
+
false
|
|
138
|
+
end
|
|
139
|
+
|
|
42
140
|
def fetch(subscription, batch_size:, timeout:)
|
|
43
141
|
subscription.fetch(batch_size, timeout:)
|
|
44
142
|
rescue NATS::Timeout
|
|
@@ -49,6 +147,7 @@ module Cosmo
|
|
|
49
147
|
|
|
50
148
|
backoff = ENV.fetch("COSMO_STREAMS_FETCH_BACKOFF", 5).to_f
|
|
51
149
|
sleep([timeout, backoff].max) # backoff before retry
|
|
150
|
+
nil
|
|
52
151
|
end
|
|
53
152
|
|
|
54
153
|
def client
|
|
@@ -58,5 +157,14 @@ module Cosmo
|
|
|
58
157
|
def stopwatch
|
|
59
158
|
Utils::Stopwatch.new
|
|
60
159
|
end
|
|
160
|
+
|
|
161
|
+
def lock(stream_name, &)
|
|
162
|
+
@locks ||= Hash.new { |h, k| h[k] = Mutex.new }
|
|
163
|
+
@locks[stream_name].synchronize(&)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def consumer_state
|
|
167
|
+
@consumer_state ||= Concurrent::Map.new
|
|
168
|
+
end
|
|
61
169
|
end
|
|
62
170
|
end
|
|
@@ -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
|
|
@@ -3,51 +3,21 @@
|
|
|
3
3
|
module Cosmo
|
|
4
4
|
module Stream
|
|
5
5
|
class Processor < ::Cosmo::Processor
|
|
6
|
-
def initialize(pool, running, options)
|
|
7
|
-
super
|
|
8
|
-
@configs = []
|
|
9
|
-
end
|
|
10
|
-
|
|
11
6
|
private
|
|
12
7
|
|
|
13
|
-
def run_loop
|
|
14
|
-
Thread.new { work_loop }
|
|
15
|
-
end
|
|
16
|
-
|
|
17
8
|
def setup
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def work_loop # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
23
|
-
shutdown = false
|
|
24
|
-
|
|
25
|
-
while running?
|
|
26
|
-
break if shutdown
|
|
27
|
-
|
|
28
|
-
@consumers.each do |(subscription, config, processor)|
|
|
29
|
-
break unless running?
|
|
30
|
-
|
|
31
|
-
begin
|
|
32
|
-
@pool.post do
|
|
33
|
-
timeout = convert_timeout(config[:fetch_timeout])
|
|
34
|
-
Logger.debug "fetching #{config.dig(:consumer, :subjects).inspect}, timeout=#{timeout}"
|
|
35
|
-
messages = fetch(subscription, batch_size: config[:batch_size], timeout:)
|
|
36
|
-
Logger.debug "fetched (#{messages&.size.to_i}) messages"
|
|
37
|
-
process(messages, processor) if messages&.any?
|
|
38
|
-
Logger.debug "processed (#{messages&.size.to_i}) messages"
|
|
39
|
-
end
|
|
40
|
-
rescue Concurrent::RejectedExecutionError
|
|
41
|
-
shutdown = true
|
|
42
|
-
break # pool doesn't accept new jobs, we are shutting down
|
|
43
|
-
end
|
|
9
|
+
@configs ||= []
|
|
10
|
+
@configs = static_config + dynamic_config
|
|
44
11
|
|
|
45
|
-
|
|
46
|
-
|
|
12
|
+
if @options[:processors]
|
|
13
|
+
pattern = Regexp.new(@options[:processors].map { "\\b#{_1}\\b" }.join("|"))
|
|
14
|
+
@configs.select! { _1[:class].name.match?(pattern) }
|
|
47
15
|
end
|
|
16
|
+
|
|
17
|
+
@configs.each { @consumers << subscribe(nil, _1) }
|
|
48
18
|
end
|
|
49
19
|
|
|
50
|
-
def process(messages, processor) # rubocop:disable Metrics/AbcSize
|
|
20
|
+
def process(messages, processor) # rubocop:disable Metrics/AbcSize
|
|
51
21
|
metadata = messages.last.metadata
|
|
52
22
|
serializer = processor.class.default_options.dig(:publisher, :serializer)
|
|
53
23
|
messages = messages.map { Message.new(_1, serializer:) }
|
|
@@ -70,25 +40,6 @@ module Cosmo
|
|
|
70
40
|
raise
|
|
71
41
|
end
|
|
72
42
|
|
|
73
|
-
def setup_configs
|
|
74
|
-
@configs = static_config + dynamic_config
|
|
75
|
-
return unless @options[:processors]
|
|
76
|
-
|
|
77
|
-
pattern = Regexp.new(@options[:processors].map { "\\b#{_1}\\b" }.join("|"))
|
|
78
|
-
@configs.select! { _1[:class].name.match?(pattern) }
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def setup_consumers
|
|
82
|
-
@configs.each do |config|
|
|
83
|
-
processor = config[:class].new
|
|
84
|
-
subjects = config.dig(:consumer, :subjects)
|
|
85
|
-
deliver_policy = Config.deliver_policy(config[:start_position])
|
|
86
|
-
consumer_config, consumer_name = config.values_at(:consumer, :consumer_name)
|
|
87
|
-
subscription = client.subscribe(subjects, consumer_name, consumer_config.merge(deliver_policy))
|
|
88
|
-
@consumers << [subscription, config, processor]
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
|
|
92
43
|
def static_config
|
|
93
44
|
Config.dig(:consumers, :streams)&.filter_map do |config|
|
|
94
45
|
next unless (klass = Utils::String.safe_constantize(config[:class]))
|
|
@@ -98,11 +49,24 @@ module Cosmo
|
|
|
98
49
|
end
|
|
99
50
|
|
|
100
51
|
def dynamic_config
|
|
101
|
-
Config.
|
|
52
|
+
Config.internal[:streams]&.map { _1.default_options.merge(class: _1) }.to_a
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def subscribe(_stream_name, config)
|
|
56
|
+
processor = config[:class].new
|
|
57
|
+
subjects = config.dig(:consumer, :subjects)
|
|
58
|
+
deliver_policy = Config.deliver_policy(config[:start_position])
|
|
59
|
+
consumer_config, consumer_name = config.values_at(:consumer, :consumer_name)
|
|
60
|
+
subscription = client.subscribe(subjects, consumer_name, consumer_config.merge(deliver_policy))
|
|
61
|
+
[subscription, config, processor]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def fetch_subjects(config)
|
|
65
|
+
config.dig(:consumer, :subjects)
|
|
102
66
|
end
|
|
103
67
|
|
|
104
|
-
def
|
|
105
|
-
timeout =
|
|
68
|
+
def fetch_timeout(config)
|
|
69
|
+
timeout = config[:fetch_timeout].to_f
|
|
106
70
|
if timeout <= 0
|
|
107
71
|
Logger.warn "Ignoring `fetch_timeout: #{timeout}` (causes high CPU usage) with #{Data::DEFAULTS[:fetch_timeout]}s instead"
|
|
108
72
|
timeout = Data::DEFAULTS[:fetch_timeout].to_f
|
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]
|
|
@@ -28,8 +29,8 @@ module Cosmo
|
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
def register # rubocop:disable Metrics/AbcSize
|
|
31
|
-
Config.
|
|
32
|
-
Config.
|
|
32
|
+
Config.internal[:streams] ||= []
|
|
33
|
+
Config.internal[:streams] << self
|
|
33
34
|
|
|
34
35
|
# settings are inherited, don't try to modify them
|
|
35
36
|
return if default_options != Data::DEFAULTS
|
data/lib/cosmo/utils/hash.rb
CHANGED
|
@@ -23,20 +23,17 @@ module Cosmo
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
base[key]
|
|
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
|
|
37
36
|
end
|
|
38
|
-
|
|
39
|
-
true
|
|
40
37
|
end
|
|
41
38
|
|
|
42
39
|
# deep set
|
|
@@ -49,17 +46,9 @@ module Cosmo
|
|
|
49
46
|
target[last_key] = value
|
|
50
47
|
end
|
|
51
48
|
|
|
52
|
-
# deep
|
|
53
|
-
def
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
hash1.merge(hash2) do |_key, old_val, new_val|
|
|
57
|
-
if old_val.is_a?(::Hash) && new_val.is_a?(::Hash)
|
|
58
|
-
merge(old_val, new_val)
|
|
59
|
-
else
|
|
60
|
-
new_val
|
|
61
|
-
end
|
|
62
|
-
end
|
|
49
|
+
# deep dup
|
|
50
|
+
def dup(hash)
|
|
51
|
+
Marshal.load(Marshal.dump(hash))
|
|
63
52
|
end
|
|
64
53
|
end
|
|
65
54
|
end
|
|
@@ -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
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
module Utils
|
|
5
|
+
class TTLCache
|
|
6
|
+
def initialize
|
|
7
|
+
@store = {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def set(key, value, ttl: nil)
|
|
11
|
+
@store[key] = [value, ttl ? Time.now + ttl : nil]
|
|
12
|
+
value
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def get(key)
|
|
16
|
+
return unless key?(key)
|
|
17
|
+
|
|
18
|
+
@store[key].first
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def fetch(key, ttl: nil)
|
|
22
|
+
return get(key) if key?(key)
|
|
23
|
+
|
|
24
|
+
result = yield
|
|
25
|
+
set(key, result, ttl: ttl)
|
|
26
|
+
result
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def key?(key)
|
|
32
|
+
exists = @store.key?(key)
|
|
33
|
+
return false unless exists
|
|
34
|
+
|
|
35
|
+
_, ttl = @store[key]
|
|
36
|
+
return true unless ttl
|
|
37
|
+
return true if Time.now < ttl
|
|
38
|
+
|
|
39
|
+
@store.delete(key)
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/cosmo/utils.rb
CHANGED
data/lib/cosmo/version.rb
CHANGED
|
@@ -135,6 +135,52 @@ button:hover, .btn:hover {
|
|
|
135
135
|
);
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
.btn-warning {
|
|
139
|
+
background: linear-gradient(
|
|
140
|
+
oklch(from var(--color-warning) calc(l + 0.06) c h),
|
|
141
|
+
var(--color-warning)
|
|
142
|
+
);
|
|
143
|
+
color: oklch(27% 0.005 256);
|
|
144
|
+
}
|
|
145
|
+
.btn-warning:hover {
|
|
146
|
+
background: linear-gradient(
|
|
147
|
+
oklch(from var(--color-warning) calc(l + 0.16) c h),
|
|
148
|
+
oklch(from var(--color-warning) calc(l + 0.1) c h)
|
|
149
|
+
);
|
|
150
|
+
color: oklch(27% 0.005 256);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.btn-success {
|
|
154
|
+
background: linear-gradient(
|
|
155
|
+
oklch(from var(--color-success) calc(l + 0.06) c h),
|
|
156
|
+
var(--color-success)
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
.btn-success:hover {
|
|
160
|
+
background: linear-gradient(
|
|
161
|
+
oklch(from var(--color-success) calc(l + 0.16) c h),
|
|
162
|
+
oklch(from var(--color-success) calc(l + 0.1) c h)
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* ── Badges ────────────────────────────────────────────────────────────── */
|
|
167
|
+
.badge {
|
|
168
|
+
border-radius: var(--space-1-2);
|
|
169
|
+
display: inline-block;
|
|
170
|
+
font-size: var(--font-size-small);
|
|
171
|
+
font-weight: 700;
|
|
172
|
+
padding: 2px var(--space);
|
|
173
|
+
white-space: nowrap;
|
|
174
|
+
}
|
|
175
|
+
.badge-success {
|
|
176
|
+
background: oklch(from var(--color-success) l c h / 15%);
|
|
177
|
+
color: var(--color-success);
|
|
178
|
+
}
|
|
179
|
+
.badge-warning {
|
|
180
|
+
background: oklch(from var(--color-warning) l c h / 20%);
|
|
181
|
+
color: oklch(from var(--color-warning) calc(l - 0.15) c h);
|
|
182
|
+
}
|
|
183
|
+
|
|
138
184
|
/* ── Alerts ────────────────────────────────────────────────────────────── */
|
|
139
185
|
.alert {
|
|
140
186
|
padding: var(--space-2x);
|
|
@@ -246,6 +292,34 @@ section > header {
|
|
|
246
292
|
z-index: auto;
|
|
247
293
|
}
|
|
248
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
|
+
|
|
249
323
|
section .nav {
|
|
250
324
|
display: flex;
|
|
251
325
|
gap: var(--space);
|
|
@@ -402,6 +476,20 @@ time {
|
|
|
402
476
|
.stream-link { color: var(--color-primary); font-weight: 600; }
|
|
403
477
|
.stream-link:hover { text-decoration: underline; }
|
|
404
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
|
+
|
|
405
493
|
/* ── Actions ───────────────────────────────────────────────────────────── */
|
|
406
494
|
.actions-form { display: flex; flex-direction: column; gap: var(--space-1-2); }
|
|
407
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
|
|
@@ -21,22 +21,48 @@ module Cosmo
|
|
|
21
21
|
ok render("streams/info", { name: name }, layout: true)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
def pause
|
|
25
|
+
name = Rack::Utils.unescape(@request.params["name"])
|
|
26
|
+
stream = API::Stream.new(name)
|
|
27
|
+
stream.pause!
|
|
28
|
+
return ok render("streams/_pause_banner", banner_locals(stream)) if @request.params["banner"]
|
|
29
|
+
|
|
30
|
+
ok render("streams/_stream_row", { stream: row_locals(stream) })
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def unpause
|
|
34
|
+
name = Rack::Utils.unescape(@request.params["name"])
|
|
35
|
+
stream = API::Stream.new(name)
|
|
36
|
+
stream.unpause!
|
|
37
|
+
return ok render("streams/_pause_banner", banner_locals(stream)) if @request.params["banner"]
|
|
38
|
+
|
|
39
|
+
ok render("streams/_stream_row", { stream: row_locals(stream) })
|
|
40
|
+
end
|
|
32
41
|
|
|
42
|
+
def _table
|
|
43
|
+
streams = API::Stream.all.reject { |s| s.name.start_with?("KV_") || s.name.start_with?("_cosmo") }.map { row_locals(_1) }
|
|
33
44
|
ok render("streams/_table", { streams: streams })
|
|
34
45
|
end
|
|
35
46
|
|
|
36
47
|
def _info
|
|
37
48
|
name = Rack::Utils.unescape(@request.params["name"])
|
|
38
|
-
|
|
39
|
-
ok render("streams/_info",
|
|
49
|
+
stream = API::Stream.new(name)
|
|
50
|
+
ok render("streams/_info", stream.info.merge(name:, paused: stream.paused?))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def row_locals(stream)
|
|
56
|
+
state, config = stream.info.values
|
|
57
|
+
{ name: stream.name, messages: state.messages, bytes: state.bytes,
|
|
58
|
+
first_seq: state.first_seq, last_seq: state.last_seq,
|
|
59
|
+
consumer_count: state.consumer_count,
|
|
60
|
+
subjects: config.subjects,
|
|
61
|
+
paused: stream.paused? }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def banner_locals(stream)
|
|
65
|
+
{ name: stream.name, paused: stream.paused? }
|
|
40
66
|
end
|
|
41
67
|
end
|
|
42
68
|
end
|