cosmonats 0.4.2 → 0.5.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 +77 -5
- data/lib/cosmo/active_job/executor.rb +9 -0
- data/lib/cosmo/active_job/options.rb +8 -5
- data/lib/cosmo/api/batch.rb +84 -0
- data/lib/cosmo/api/counter.rb +17 -6
- data/lib/cosmo/api/cron/entry.rb +1 -1
- data/lib/cosmo/api/cron.rb +1 -1
- data/lib/cosmo/api/kv.rb +7 -0
- data/lib/cosmo/api.rb +1 -0
- data/lib/cosmo/batch/callback.rb +17 -0
- data/lib/cosmo/batch/dispatcher.rb +86 -0
- data/lib/cosmo/batch.rb +164 -0
- data/lib/cosmo/cli.rb +31 -25
- data/lib/cosmo/config.rb +10 -2
- data/lib/cosmo/job/data.rb +14 -8
- data/lib/cosmo/job/processor.rb +78 -18
- data/lib/cosmo/job.rb +46 -9
- data/lib/cosmo/processor.rb +7 -7
- data/lib/cosmo/utils/duration.rb +33 -0
- data/lib/cosmo/utils.rb +1 -0
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +9 -1
- data/lib/cosmo/web/controllers/batches.rb +21 -0
- data/lib/cosmo/web/controllers/jobs.rb +7 -4
- data/lib/cosmo/web/views/batches/_table.erb +47 -0
- data/lib/cosmo/web/views/batches/index.erb +10 -0
- data/lib/cosmo/web/views/jobs/_enqueued.erb +37 -35
- data/lib/cosmo/web/views/jobs/_tabs.erb +2 -0
- data/lib/cosmo/web/views/layout.erb +1 -1
- data/lib/cosmo/web.rb +3 -0
- data/lib/cosmo.rb +1 -0
- data/sig/cosmo/active_job/executor.rbs +2 -0
- data/sig/cosmo/api/batch.rbs +43 -0
- data/sig/cosmo/batch/callback.rbs +9 -0
- data/sig/cosmo/batch/dispatcher.rbs +23 -0
- data/sig/cosmo/batch.rbs +49 -0
- data/sig/cosmo/config.rbs +2 -0
- data/sig/cosmo/job/data.rbs +3 -1
- data/sig/cosmo/job/processor.rbs +17 -1
- data/sig/cosmo/job.rbs +10 -0
- data/sig/cosmo/processor.rbs +3 -3
- data/sig/cosmo/utils/duration.rbs +11 -0
- metadata +28 -1
data/lib/cosmo/cli.rb
CHANGED
|
@@ -16,6 +16,8 @@ module Cosmo
|
|
|
16
16
|
|
|
17
17
|
def run
|
|
18
18
|
flags, command, options = parse
|
|
19
|
+
return run_setup(flags) if flags[:setup]
|
|
20
|
+
|
|
19
21
|
load_config(flags)
|
|
20
22
|
puts self.class.banner
|
|
21
23
|
boot_application
|
|
@@ -53,6 +55,34 @@ module Cosmo
|
|
|
53
55
|
Config.set(:timeout, flags[:timeout]) if flags[:timeout]
|
|
54
56
|
end
|
|
55
57
|
|
|
58
|
+
def run_setup(flags)
|
|
59
|
+
load_config(flags)
|
|
60
|
+
boot_application
|
|
61
|
+
|
|
62
|
+
Config[:setup]&.each do |type, configs|
|
|
63
|
+
next if type == :cron
|
|
64
|
+
|
|
65
|
+
first_line = true
|
|
66
|
+
configs.each do |name, config|
|
|
67
|
+
meta = { metadata: { "_cosmo.type" => "jobs" } } if type == :jobs
|
|
68
|
+
Client.instance.setup_stream(name.to_s, config.merge(Hash(meta)))
|
|
69
|
+
first_line ? print("Stream is ready: #{name}") : print(", #{name}")
|
|
70
|
+
first_line = false
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
puts
|
|
75
|
+
schedules = Config.dig(:setup, :cron)&.reduce(0) do |sum, (name, entry)|
|
|
76
|
+
class_name = entry.delete(:class)
|
|
77
|
+
API::Cron.instance.upsert!(**entry, name: name, class_name: class_name)
|
|
78
|
+
sum + 1
|
|
79
|
+
end.to_i
|
|
80
|
+
|
|
81
|
+
puts "Cron sync complete: #{schedules} schedule(s) registered" unless schedules.zero?
|
|
82
|
+
puts "Cosmo streams#{" and cron schedules" unless schedules.zero?} set up successfully."
|
|
83
|
+
exit(0)
|
|
84
|
+
end
|
|
85
|
+
|
|
56
86
|
def boot_application
|
|
57
87
|
boot_path = File.expand_path("config/boot.rb")
|
|
58
88
|
require boot_path if File.exist?(boot_path)
|
|
@@ -104,31 +134,7 @@ module Cosmo
|
|
|
104
134
|
end
|
|
105
135
|
|
|
106
136
|
o.on "-S", "--setup", "Create/update streams and sync cron schedules, then exit" do
|
|
107
|
-
|
|
108
|
-
boot_application
|
|
109
|
-
|
|
110
|
-
Config[:setup]&.each do |type, configs|
|
|
111
|
-
next if type == :cron
|
|
112
|
-
|
|
113
|
-
first_line = true
|
|
114
|
-
configs.each do |name, config|
|
|
115
|
-
meta = { metadata: { "_cosmo.type" => "jobs" } } if type == :jobs
|
|
116
|
-
Client.instance.setup_stream(name.to_s, config.merge(Hash(meta)))
|
|
117
|
-
first_line ? print("Stream is ready: #{name}") : print(", #{name}")
|
|
118
|
-
first_line = false
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
puts
|
|
123
|
-
schedules = Config.dig(:setup, :cron)&.reduce(0) do |sum, (name, entry)|
|
|
124
|
-
class_name = entry.delete(:class)
|
|
125
|
-
API::Cron.instance.upsert!(**entry, name: name, class_name: class_name)
|
|
126
|
-
sum + 1
|
|
127
|
-
end.to_i
|
|
128
|
-
|
|
129
|
-
puts "Cron sync complete: #{schedules} schedule(s) registered" unless schedules.zero?
|
|
130
|
-
puts "Cosmo streams#{" and cron schedules" unless schedules.zero?} set up successfully."
|
|
131
|
-
exit(0)
|
|
137
|
+
flags[:setup] = true
|
|
132
138
|
end
|
|
133
139
|
|
|
134
140
|
o.on_tail "-v", "--version", "Print version" do
|
data/lib/cosmo/config.rb
CHANGED
|
@@ -14,6 +14,10 @@ module Cosmo
|
|
|
14
14
|
delegate %i[[] fetch dig to_h set load] => :instance
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
def self.to_ns(seconds)
|
|
18
|
+
(seconds.to_f * NANO).to_i
|
|
19
|
+
end
|
|
20
|
+
|
|
17
21
|
def self.parse_file(path)
|
|
18
22
|
YAML.load_file(path, aliases: true).tap { normalize!(_1) }
|
|
19
23
|
end
|
|
@@ -21,10 +25,14 @@ module Cosmo
|
|
|
21
25
|
def self.normalize!(config) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
22
26
|
Utils::Hash.symbolize_keys!(config)
|
|
23
27
|
|
|
28
|
+
config[:timeout] = Utils::Duration.parse(config[:timeout]) if config[:timeout]
|
|
29
|
+
config[:batch_expiry] = Utils::Duration.parse(config[:batch_expiry]) if config[:batch_expiry]
|
|
30
|
+
|
|
24
31
|
config[:consumers]&.each_key do |name|
|
|
25
32
|
config[:consumers][name].each do |stream_name, c|
|
|
26
33
|
next unless c
|
|
27
34
|
|
|
35
|
+
c[:ack_wait] = Utils::Duration.parse(c[:ack_wait]) if c[:ack_wait]
|
|
28
36
|
c[:subject] = format(c[:subject], { name: stream_name }) if c[:subject]
|
|
29
37
|
c[:subjects] = c[:subjects].map { |s| format(s, name: stream_name) } if c[:subjects]
|
|
30
38
|
end
|
|
@@ -35,8 +43,8 @@ module Cosmo
|
|
|
35
43
|
|
|
36
44
|
config[:setup][type]&.each_key do |name|
|
|
37
45
|
c = config[:setup][type][name]
|
|
38
|
-
c[:max_age] = c[:max_age]
|
|
39
|
-
c[:duplicate_window] = c[:duplicate_window]
|
|
46
|
+
c[:max_age] = to_ns(Utils::Duration.parse(c[:max_age])) if c[:max_age]
|
|
47
|
+
c[:duplicate_window] = to_ns(Utils::Duration.parse(c[:duplicate_window])) if c[:duplicate_window]
|
|
40
48
|
c[:subjects] = c[:subjects].map { |s| format(s, name: name) } if c[:subjects]
|
|
41
49
|
|
|
42
50
|
next unless type == :jobs # Every jobs stream supports NATS 2.14 message scheduling.
|
data/lib/cosmo/job/data.rb
CHANGED
|
@@ -7,6 +7,10 @@ module Cosmo
|
|
|
7
7
|
class Data
|
|
8
8
|
DEFAULTS = { stream: :default, retry: 3, dead: true, limit: nil }.freeze
|
|
9
9
|
|
|
10
|
+
def self.default_retry
|
|
11
|
+
Config[:max_retries] || DEFAULTS[:retry]
|
|
12
|
+
end
|
|
13
|
+
|
|
10
14
|
attr_reader :jid
|
|
11
15
|
|
|
12
16
|
def initialize(class_name, args, options = nil)
|
|
@@ -22,6 +26,10 @@ module Cosmo
|
|
|
22
26
|
@jid = SecureRandom.hex(12)
|
|
23
27
|
end
|
|
24
28
|
|
|
29
|
+
def batch_id
|
|
30
|
+
@options[:batch_id]
|
|
31
|
+
end
|
|
32
|
+
|
|
25
33
|
def stream(target: false)
|
|
26
34
|
return @options[:stream] if target
|
|
27
35
|
|
|
@@ -33,13 +41,8 @@ module Cosmo
|
|
|
33
41
|
end
|
|
34
42
|
|
|
35
43
|
def as_json
|
|
36
|
-
{
|
|
37
|
-
|
|
38
|
-
class: @class_name,
|
|
39
|
-
args: @args,
|
|
40
|
-
retry: retries,
|
|
41
|
-
dead: dead
|
|
42
|
-
}
|
|
44
|
+
json = { jid: jid, class: @class_name, args: @args, retry: retries, dead: dead }
|
|
45
|
+
batch_id ? json.merge(batch_id: batch_id) : json
|
|
43
46
|
end
|
|
44
47
|
|
|
45
48
|
def to_json(*_args)
|
|
@@ -63,7 +66,10 @@ module Cosmo
|
|
|
63
66
|
end
|
|
64
67
|
|
|
65
68
|
def retries
|
|
66
|
-
@options[:retry].nil?
|
|
69
|
+
return self.class.default_retry if @options[:retry].nil?
|
|
70
|
+
return 0 if @options[:retry] == false
|
|
71
|
+
|
|
72
|
+
@options[:retry]
|
|
67
73
|
end
|
|
68
74
|
|
|
69
75
|
def dead
|
data/lib/cosmo/job/processor.rb
CHANGED
|
@@ -42,9 +42,14 @@ module Cosmo
|
|
|
42
42
|
client.publish(subject, message.data, headers: headers)
|
|
43
43
|
message.ack
|
|
44
44
|
else
|
|
45
|
-
|
|
46
|
-
message.nak(delay: delay_ns)
|
|
45
|
+
message.nak(delay: Config.to_ns(execute_at - now))
|
|
47
46
|
end
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
# A transient failure here (e.g. a JetStream publish timeout) must not be allowed
|
|
49
|
+
# to escape #each and kill this thread — schedule_loop only runs once per processor,
|
|
50
|
+
# so an unhandled exception would silently stop all future scheduled-job dispatch.
|
|
51
|
+
Logger.error e
|
|
52
|
+
message.nak rescue nil
|
|
48
53
|
end
|
|
49
54
|
|
|
50
55
|
break unless running?
|
|
@@ -65,6 +70,7 @@ module Cosmo
|
|
|
65
70
|
unless worker_class
|
|
66
71
|
Logger.error ArgumentError.new("#{data[:class]} class not found")
|
|
67
72
|
move_message(message, data)
|
|
73
|
+
notify_batch(data, success: false)
|
|
68
74
|
return
|
|
69
75
|
end
|
|
70
76
|
|
|
@@ -80,20 +86,21 @@ module Cosmo
|
|
|
80
86
|
Logger.with(jid: data[:jid])
|
|
81
87
|
Logger.info "start"
|
|
82
88
|
|
|
83
|
-
instance = worker_class
|
|
89
|
+
instance = build_worker(worker_class, data, message)
|
|
84
90
|
perform_job(instance, data: data, message: message, duration: duration)
|
|
85
91
|
|
|
86
92
|
message.ack
|
|
93
|
+
notify_batch(data, success: true)
|
|
87
94
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "done" }
|
|
88
95
|
true
|
|
89
|
-
rescue Timeout::Error
|
|
96
|
+
rescue Timeout::Error => e
|
|
90
97
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[timeout]" }
|
|
91
|
-
dropped = handle_failure(message, data)
|
|
98
|
+
dropped = handle_failure(worker_class, message, data, e)
|
|
92
99
|
false if dropped
|
|
93
100
|
rescue StandardError => e
|
|
94
101
|
Logger.debug e
|
|
95
102
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[error]" }
|
|
96
|
-
dropped = handle_failure(message, data)
|
|
103
|
+
dropped = handle_failure(worker_class, message, data, e)
|
|
97
104
|
false if dropped
|
|
98
105
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
99
106
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[exception]" }
|
|
@@ -105,6 +112,16 @@ module Cosmo
|
|
|
105
112
|
Logger.debug "processed message #{message.inspect}"
|
|
106
113
|
end
|
|
107
114
|
|
|
115
|
+
def build_worker(worker_class, data, message)
|
|
116
|
+
worker_class.new.tap do |worker|
|
|
117
|
+
worker.jid = data[:jid]
|
|
118
|
+
worker.enqueued_at = message.metadata.timestamp
|
|
119
|
+
worker.attempt = message.metadata.num_delivered
|
|
120
|
+
worker.scheduled_by = message.header&.dig("Nats-Scheduler")
|
|
121
|
+
worker.batch_id = data[:batch_id]
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
108
125
|
# Tries to acquire a concurrency slot for the job.
|
|
109
126
|
# Returns the slot key (String) on success, or false if all slots are
|
|
110
127
|
# taken (a message is NAK'd with a delay of +retry_in+ before returning
|
|
@@ -115,7 +132,7 @@ module Cosmo
|
|
|
115
132
|
slot = Limit.instance.acquire(key, jid: data[:jid], limit: options[:limit], duration: options[:duration])
|
|
116
133
|
return slot if slot
|
|
117
134
|
|
|
118
|
-
message.nak(delay: options[:retry_in]
|
|
135
|
+
message.nak(delay: Config.to_ns(options[:retry_in]))
|
|
119
136
|
Logger.debug "concurrency limit reached for #{data[:class]}, re-queueing back #{data[:jid]}"
|
|
120
137
|
false
|
|
121
138
|
rescue NATS::Error => e
|
|
@@ -126,29 +143,72 @@ module Cosmo
|
|
|
126
143
|
false
|
|
127
144
|
end
|
|
128
145
|
|
|
129
|
-
def handle_failure(message, data) # rubocop:disable Naming/PredicateMethod
|
|
146
|
+
def handle_failure(worker_class, message, data, exception) # rubocop:disable Naming/PredicateMethod
|
|
130
147
|
current_attempt = message.metadata.num_delivered
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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.
|
|
137
|
-
delay_ns = ((current_attempt**4) + 15) * Config::NANO
|
|
138
|
-
message.nak(delay: delay_ns)
|
|
148
|
+
desired_retries = data[:retry].to_i + 1
|
|
149
|
+
capped_at = deliver_cap(message.metadata.stream, desired_retries)
|
|
150
|
+
|
|
151
|
+
if current_attempt < (capped_at || desired_retries)
|
|
152
|
+
nak_message(worker_class, message, data, current_attempt, exception)
|
|
139
153
|
return false
|
|
140
154
|
end
|
|
141
155
|
|
|
156
|
+
warn_capped(message, data, capped_at) if capped_at
|
|
142
157
|
data[:dead] ? move_message(message, data) : drop_message(message, data)
|
|
158
|
+
notify_batch(data, success: false)
|
|
143
159
|
true
|
|
144
160
|
end
|
|
145
161
|
|
|
162
|
+
def notify_batch(data, success:)
|
|
163
|
+
return unless data[:batch_id]
|
|
164
|
+
|
|
165
|
+
Batch.notify(data[:batch_id], data[:jid], success: success)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The message is NAK'd with an explicit delay (default backoff, or the job class's own +retry_in+ handler).
|
|
169
|
+
def nak_message(worker_class, message, data, current_attempt, exception)
|
|
170
|
+
message.nak(delay: Config.to_ns(retry_delay(worker_class, data, current_attempt, exception)))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def warn_capped(message, data, capped_at)
|
|
174
|
+
consumer_name = consumer_entry(message.metadata.stream)&.dig(1, :consumer)
|
|
175
|
+
Logger.warn "#{data[:class]} configured retry: #{data[:retry]} exceeds max_deliver: #{capped_at} " \
|
|
176
|
+
"on #{consumer_name}; giving up early to avoid a stranded message"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Returns the consumer's configured +max_deliver+ when it's lower than the job's own configured
|
|
180
|
+
# retry count (so we should give up a bit early instead of NAK'ing into a redelivery that'll never
|
|
181
|
+
# come), or +nil+ when the job's own retry count is already the binding constraint.
|
|
182
|
+
def deliver_cap(stream_name, desired_retries)
|
|
183
|
+
max_deliver = consumer_entry(stream_name)&.dig(1, :max_deliver).to_i
|
|
184
|
+
max_deliver if max_deliver.positive? && max_deliver < desired_retries
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def consumer_entry(stream_name)
|
|
188
|
+
@consumers.find { |(_, config, _)| config[:stream].to_s == stream_name.to_s }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def retry_delay(worker_class, data, current_attempt, exception)
|
|
192
|
+
handler = worker_class.retry_in(data)
|
|
193
|
+
return default_retry_delay(current_attempt) unless handler
|
|
194
|
+
|
|
195
|
+
delay = handler.call(current_attempt, exception)
|
|
196
|
+
delay.is_a?(Numeric) && delay.positive? ? delay : default_retry_delay(current_attempt)
|
|
197
|
+
rescue StandardError => e
|
|
198
|
+
Logger.error e
|
|
199
|
+
default_retry_delay(current_attempt)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def default_retry_delay(current_attempt)
|
|
203
|
+
(current_attempt**4) + 15
|
|
204
|
+
end
|
|
205
|
+
|
|
146
206
|
def subscribe(stream_name, config)
|
|
147
207
|
config = config.dup
|
|
148
208
|
config[:batch_size] = 1
|
|
149
209
|
config[:stream] = stream_name
|
|
150
|
-
|
|
151
|
-
subscription = client.subscribe(config[:subject],
|
|
210
|
+
config[:consumer] = "consumer-#{stream_name}"
|
|
211
|
+
subscription = client.subscribe(config[:subject], config[:consumer], config.except(:subject, :priority, :stream, :batch_size, :consumer))
|
|
152
212
|
[subscription, config, nil]
|
|
153
213
|
end
|
|
154
214
|
|
data/lib/cosmo/job.rb
CHANGED
|
@@ -11,32 +11,48 @@ module Cosmo
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
module ClassMethods
|
|
14
|
-
# @option config [Symbol]
|
|
15
|
-
# @option config [Integer] :retry
|
|
16
|
-
#
|
|
17
|
-
#
|
|
14
|
+
# @option config [Symbol] :stream NATS stream to publish to (default: :default)
|
|
15
|
+
# @option config [Integer, Boolean] :retry max delivery attempts before giving up (default: +max_retries+
|
|
16
|
+
# from cosmo.yml, or 3 if unset). +false+ is treated as 0 (no retries). Should stay comfortably under
|
|
17
|
+
# the assigned stream's consumer +max_deliver+ (a coarse, shared safety ceiling, not a per-job budget) --
|
|
18
|
+
# a job whose +retry:+ exceeds it is capped and dead-lettered a delivery early, with a warning logged.
|
|
19
|
+
# @option config [Boolean] :dead move to dead-letter stream after retries exhausted (default: true)
|
|
20
|
+
# @option config [Hash] :limit execution limits:
|
|
18
21
|
#
|
|
19
22
|
# limit: { duration: 30 }
|
|
20
23
|
# limit: { duration: 30, concurrency: 3 }
|
|
21
24
|
# limit: { duration: 30, concurrency: { to: 3, key: ->(id) { id } } }
|
|
22
25
|
# limit: { duration: 30, concurrency: 3, retry_in: 5 }
|
|
23
26
|
#
|
|
24
|
-
# @option config [Integer] :"limit[:duration]"
|
|
27
|
+
# @option config [Integer] :"limit[:duration]" hard execution timeout in seconds. The job thread is
|
|
25
28
|
# killed after this many seconds and counts as a failed attempt (retried with exponential backoff,
|
|
26
29
|
# moved to DLQ after retries exhausted).
|
|
27
|
-
# @option config [Integer, Hash] :"limit[:concurrency]"
|
|
30
|
+
# @option config [Integer, Hash] :"limit[:concurrency]" caps how many instances run at once across all
|
|
28
31
|
# workers. Jobs that cannot acquire a slot are NAK'd (see +retry_in+) so they are not re-delivered until
|
|
29
32
|
# the slot is likely free. Requires +duration+.
|
|
30
33
|
# Pass an Integer for a class-wide cap, or <tt>{ to: N, key: ->(args) {} }</tt> to scope per key.
|
|
31
|
-
# @option config [Integer] :"limit[:retry_in]"
|
|
34
|
+
# @option config [Integer] :"limit[:retry_in]" seconds to wait before NATS redelivers a job that was
|
|
32
35
|
# NAK'd for lack of a concurrency slot (default: half of +duration+). Counts against the same delivery
|
|
33
36
|
# counter as any other retry -- a job stuck behind the concurrency limit for enough consecutive
|
|
34
37
|
# attempts is dropped/DLQ'd exactly like one that keeps failing outright.
|
|
38
|
+
# @option config [Proc] :retry_in <tt>->(count, exception) { }</tt> returns a number of seconds to
|
|
39
|
+
# wait before redelivering a *failed* job. +count+ is a 1-based delivery attempt that just failed.
|
|
40
|
+
# Falls back to the default backoff (<tt>attempt**4 + 15</tt> seconds) if not set or the proc returns
|
|
41
|
+
# a non-numeric/non-positive value, or if it raises.
|
|
42
|
+
#
|
|
43
|
+
# Caveat when combined with +limit[:concurrency]+: when there's no free slot to run in, the message is
|
|
44
|
+
# put back on the stream using the +limit[:retry_in]+ delay described above, and that counts as an
|
|
45
|
+
# attempt too -- the same +count+ goes up whether the job was turned away for lack of a free slot (via
|
|
46
|
+
# +limit[:retry_in]+) or actually ran and failed. So a job that gets turned away twice for lack of a
|
|
47
|
+
# slot, then finally runs and fails, calls this handler with +count == 3+, not 1. Don't read +count+ as
|
|
48
|
+
# "how many times perform has actually run and failed" when concurrency limits are in play.
|
|
35
49
|
def options(**config)
|
|
36
50
|
if config[:limit] && config.dig(:limit, :concurrency) && !config.dig(:limit, :duration).to_i.positive?
|
|
37
51
|
raise ArgumentError, "limit: duration is required when concurrency is set"
|
|
38
52
|
end
|
|
39
53
|
|
|
54
|
+
raise ArgumentError, "retry_in must be callable, e.g. ->(count, exception) { ... }" if config[:retry_in] && !config[:retry_in].respond_to?(:call)
|
|
55
|
+
|
|
40
56
|
default_options.merge!(config)
|
|
41
57
|
end
|
|
42
58
|
alias cosmo_options options
|
|
@@ -45,6 +61,13 @@ module Cosmo
|
|
|
45
61
|
!!concurrency_options
|
|
46
62
|
end
|
|
47
63
|
|
|
64
|
+
# Returns the +retry_in+ Proc/lambda (taking +(count, exception)+) configured for this job class, or
|
|
65
|
+
# +nil+ when unset. Overridable by wrapper job classes (e.g. the ActiveJob executor) that need to
|
|
66
|
+
# resolve it from something other than +self+.
|
|
67
|
+
def retry_in(_data = nil)
|
|
68
|
+
default_options[:retry_in]
|
|
69
|
+
end
|
|
70
|
+
|
|
48
71
|
# Returns a normalized concurrency config hash, or +nil+ when not configured.
|
|
49
72
|
# Always contains +:limit+, +:key+, +:duration+, and +:retry_in+.
|
|
50
73
|
def concurrency_options
|
|
@@ -71,6 +94,8 @@ module Cosmo
|
|
|
71
94
|
end
|
|
72
95
|
|
|
73
96
|
def perform(*args, async: true, **options)
|
|
97
|
+
batch = Batch.current if async
|
|
98
|
+
options[:batch_id] = batch.bid if batch
|
|
74
99
|
data = Data.new(name, args, default_options.merge(options))
|
|
75
100
|
unless async
|
|
76
101
|
payload = Utils::Json.parse(data.to_args[1])
|
|
@@ -80,7 +105,19 @@ module Cosmo
|
|
|
80
105
|
return
|
|
81
106
|
end
|
|
82
107
|
|
|
108
|
+
publish(data, batch)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The batch is reserved a pending slot before we know the publish will
|
|
112
|
+
# succeed (must happen in that order -- see Batch#jobs). Roll it back
|
|
113
|
+
# if it never actually made it onto the stream, so the batch doesn't
|
|
114
|
+
# hang waiting for a completion that will never arrive.
|
|
115
|
+
def publish(data, batch)
|
|
116
|
+
batch&.register_job!
|
|
83
117
|
Publisher.publish_job(data)
|
|
118
|
+
rescue StandardError
|
|
119
|
+
batch&.rollback_job!
|
|
120
|
+
raise
|
|
84
121
|
end
|
|
85
122
|
|
|
86
123
|
def perform_async(*args)
|
|
@@ -100,7 +137,7 @@ module Cosmo
|
|
|
100
137
|
end
|
|
101
138
|
|
|
102
139
|
def default_options
|
|
103
|
-
@default_options ||= (superclass.respond_to?(:default_options) ? superclass.default_options : Data::DEFAULTS).dup
|
|
140
|
+
@default_options ||= (superclass.respond_to?(:default_options) ? superclass.default_options : Data::DEFAULTS.merge(retry: Data.default_retry)).dup
|
|
104
141
|
end
|
|
105
142
|
|
|
106
143
|
private
|
|
@@ -110,7 +147,7 @@ module Cosmo
|
|
|
110
147
|
end
|
|
111
148
|
end
|
|
112
149
|
|
|
113
|
-
attr_accessor :jid
|
|
150
|
+
attr_accessor :jid, :batch_id, :enqueued_at, :attempt, :scheduled_by
|
|
114
151
|
|
|
115
152
|
def perform(...)
|
|
116
153
|
raise NotImplementedError, "#{self.class}#perform must be implemented"
|
data/lib/cosmo/processor.rb
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Cosmo
|
|
4
4
|
class Processor
|
|
5
|
-
STREAM_PAUSED_RECHECK_TTL =
|
|
6
|
-
STREAMS_PAUSED_IDLE_SLEEP =
|
|
7
|
-
STREAM_EMPTY_BACKOFF_MAX =
|
|
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
|
+
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
|
+
STREAM_EMPTY_BACKOFF_MAX = "5s" # Max sleep between empty fetches (override via COSMO_STREAM_EMPTY_BACKOFF_MAX)
|
|
8
8
|
|
|
9
9
|
def self.run(...)
|
|
10
10
|
new(...).tap(&:run)
|
|
@@ -58,8 +58,8 @@ module Cosmo
|
|
|
58
58
|
break unless running?
|
|
59
59
|
|
|
60
60
|
stream_name = config[:stream].to_s
|
|
61
|
-
ttl = ENV.fetch("COSMO_STREAM_PAUSED_RECHECK_TTL", STREAM_PAUSED_RECHECK_TTL)
|
|
62
|
-
if @cache.fetch(stream_name, ttl:) { API::Stream.new(stream_name).paused? }
|
|
61
|
+
ttl = Utils::Duration.parse(ENV.fetch("COSMO_STREAM_PAUSED_RECHECK_TTL", STREAM_PAUSED_RECHECK_TTL))
|
|
62
|
+
if @cache.fetch("#{stream_name}:paused", ttl:) { API::Stream.new(stream_name).paused? }
|
|
63
63
|
Logger.debug "stream #{stream_name} is paused, skipping fetch"
|
|
64
64
|
next
|
|
65
65
|
end
|
|
@@ -86,7 +86,7 @@ module Cosmo
|
|
|
86
86
|
consumer_state.delete(stream_name)
|
|
87
87
|
process(messages, processor)
|
|
88
88
|
else
|
|
89
|
-
max_backoff = ENV.fetch("COSMO_STREAM_EMPTY_BACKOFF_MAX", STREAM_EMPTY_BACKOFF_MAX)
|
|
89
|
+
max_backoff = Utils::Duration.parse(ENV.fetch("COSMO_STREAM_EMPTY_BACKOFF_MAX", STREAM_EMPTY_BACKOFF_MAX))
|
|
90
90
|
consumer_state.compute(stream_name) do |current|
|
|
91
91
|
count = (current&.first || 0) + 1
|
|
92
92
|
backoff = [timeout * (2**(count - 1)), max_backoff].min
|
|
@@ -103,7 +103,7 @@ module Cosmo
|
|
|
103
103
|
break unless running?
|
|
104
104
|
|
|
105
105
|
if all_paused
|
|
106
|
-
period = ENV.fetch("COSMO_STREAMS_PAUSED_IDLE_SLEEP", STREAMS_PAUSED_IDLE_SLEEP)
|
|
106
|
+
period = Utils::Duration.parse(ENV.fetch("COSMO_STREAMS_PAUSED_IDLE_SLEEP", STREAMS_PAUSED_IDLE_SLEEP))
|
|
107
107
|
Logger.debug "all streams paused, sleep=#{period}"
|
|
108
108
|
sleep(period)
|
|
109
109
|
elsif all_empty
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
module Utils
|
|
5
|
+
# Parses human-friendly duration strings ("20s", "1h", "7d") into seconds, so
|
|
6
|
+
# config values don't have to be spelled out as bare, uncommented integers.
|
|
7
|
+
# Numbers pass through unchanged, since existing config already uses plain seconds.
|
|
8
|
+
module Duration
|
|
9
|
+
UNITS = {
|
|
10
|
+
"s" => 1,
|
|
11
|
+
"m" => 60,
|
|
12
|
+
"h" => 3600,
|
|
13
|
+
"d" => 86_400,
|
|
14
|
+
"w" => 604_800,
|
|
15
|
+
"mo" => 2_592_000, # 30 days
|
|
16
|
+
"y" => 31_536_000
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def parse(value)
|
|
22
|
+
return value if value.is_a?(Numeric)
|
|
23
|
+
|
|
24
|
+
str = value.to_s
|
|
25
|
+
match = str.match(/\A(\d+)(mo|[smhdwy])\z/)
|
|
26
|
+
return match[1].to_i * UNITS[match[2]] if match
|
|
27
|
+
return str.to_f if str.match?(/\A\d+(\.\d+)?\z/)
|
|
28
|
+
|
|
29
|
+
raise ArgumentError, "invalid duration: #{value}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/cosmo/utils.rb
CHANGED
data/lib/cosmo/version.rb
CHANGED
|
@@ -180,6 +180,14 @@ button:hover, .btn:hover {
|
|
|
180
180
|
background: oklch(from var(--color-warning) l c h / 20%);
|
|
181
181
|
color: oklch(from var(--color-warning) calc(l - 0.15) c h);
|
|
182
182
|
}
|
|
183
|
+
.badge-danger {
|
|
184
|
+
background: oklch(from var(--color-danger) l c h / 15%);
|
|
185
|
+
color: var(--color-danger);
|
|
186
|
+
}
|
|
187
|
+
.badge-info {
|
|
188
|
+
background: oklch(from var(--color-info) l c h / 15%);
|
|
189
|
+
color: var(--color-info);
|
|
190
|
+
}
|
|
183
191
|
|
|
184
192
|
/* ── Alerts ────────────────────────────────────────────────────────────── */
|
|
185
193
|
.alert {
|
|
@@ -507,7 +515,7 @@ details summary {
|
|
|
507
515
|
font-size: var(--font-size-small);
|
|
508
516
|
user-select: none;
|
|
509
517
|
}
|
|
510
|
-
details summary code { display: inline; }
|
|
518
|
+
details summary code { display: inline; user-select: text; }
|
|
511
519
|
details[open] summary { margin-bottom: var(--space); }
|
|
512
520
|
details code { display: block; max-width: 400px; white-space: pre-wrap; }
|
|
513
521
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cosmo/web/controllers/application"
|
|
4
|
+
|
|
5
|
+
module Cosmo
|
|
6
|
+
class Web
|
|
7
|
+
module Controllers
|
|
8
|
+
class Batches < Application
|
|
9
|
+
def index
|
|
10
|
+
content_for :title, "Batches"
|
|
11
|
+
ok render("batches/index", layout: true)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def _table
|
|
15
|
+
limit = (params["limit"] || API::Batch::LIMIT).to_i
|
|
16
|
+
ok render("batches/_table", { batches: API::Batch.all(limit:) })
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -76,10 +76,13 @@ module Cosmo
|
|
|
76
76
|
stream_name, stream_names = streams
|
|
77
77
|
limit = (params["limit"] || API::Stream::LIMIT).to_i
|
|
78
78
|
page = [params["page"].to_i, 1].max
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
|
|
80
|
+
unless stream_name.to_s.empty?
|
|
81
|
+
stream = API::Stream.new(stream_name)
|
|
82
|
+
total = stream.total
|
|
83
|
+
jobs = stream.messages(page:, limit:)
|
|
84
|
+
total_pages = (total.to_f / limit).ceil
|
|
85
|
+
end
|
|
83
86
|
|
|
84
87
|
ok render("jobs/_enqueued", { jobs:, total:, stream_name:, stream_names:, page:, limit:, total_pages: })
|
|
85
88
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<% if @batches.empty? -%>
|
|
2
|
+
<div class="alert alert-success">No batches tracked.</div>
|
|
3
|
+
<% else -%>
|
|
4
|
+
<div class="table-container">
|
|
5
|
+
<table>
|
|
6
|
+
<thead>
|
|
7
|
+
<tr>
|
|
8
|
+
<th>Batch</th>
|
|
9
|
+
<th>Parent</th>
|
|
10
|
+
<th>Status</th>
|
|
11
|
+
<th>Total</th>
|
|
12
|
+
<th>Succeeded</th>
|
|
13
|
+
<th>Failed</th>
|
|
14
|
+
<th>Created</th>
|
|
15
|
+
</tr>
|
|
16
|
+
</thead>
|
|
17
|
+
<tbody>
|
|
18
|
+
<% @batches.each do |b| -%>
|
|
19
|
+
<% stats = b.stats -%>
|
|
20
|
+
<tr>
|
|
21
|
+
<td><code><%= h(b.bid) %></code></td>
|
|
22
|
+
<td>
|
|
23
|
+
<% if b.parent_id -%>
|
|
24
|
+
<code><%= h(b.parent_id) %></code>
|
|
25
|
+
<% else -%>
|
|
26
|
+
<span class="text-muted">—</span>
|
|
27
|
+
<% end -%>
|
|
28
|
+
</td>
|
|
29
|
+
<td>
|
|
30
|
+
<% if !b.ready? -%>
|
|
31
|
+
<span class="badge badge-info">Open</span>
|
|
32
|
+
<% elsif stats[:failed].to_i.positive? -%>
|
|
33
|
+
<span class="badge badge-danger">Complete</span>
|
|
34
|
+
<% else -%>
|
|
35
|
+
<span class="badge badge-success">Success</span>
|
|
36
|
+
<% end -%>
|
|
37
|
+
</td>
|
|
38
|
+
<td><%= format_numbers(stats[:total]) %></td>
|
|
39
|
+
<td><%= format_numbers(stats[:succeeded]) %></td>
|
|
40
|
+
<td><%= format_numbers(stats[:failed]) %></td>
|
|
41
|
+
<td><%= format_timestamp(b.created_at) %></td>
|
|
42
|
+
</tr>
|
|
43
|
+
<% end -%>
|
|
44
|
+
</tbody>
|
|
45
|
+
</table>
|
|
46
|
+
</div>
|
|
47
|
+
<% end -%>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<section>
|
|
2
|
+
<header><%= render('jobs/_tabs') %></header>
|
|
3
|
+
|
|
4
|
+
<div id="batches-table"
|
|
5
|
+
hx-get="<%= url_for('/batches/_table') %>"
|
|
6
|
+
hx-trigger="load, every 5s"
|
|
7
|
+
hx-swap="innerHTML">
|
|
8
|
+
<div class="alert alert-info">Loading batches…</div>
|
|
9
|
+
</div>
|
|
10
|
+
</section>
|