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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +77 -5
  3. data/lib/cosmo/active_job/executor.rb +9 -0
  4. data/lib/cosmo/active_job/options.rb +8 -5
  5. data/lib/cosmo/api/batch.rb +84 -0
  6. data/lib/cosmo/api/counter.rb +17 -6
  7. data/lib/cosmo/api/cron/entry.rb +1 -1
  8. data/lib/cosmo/api/cron.rb +1 -1
  9. data/lib/cosmo/api/kv.rb +7 -0
  10. data/lib/cosmo/api.rb +1 -0
  11. data/lib/cosmo/batch/callback.rb +17 -0
  12. data/lib/cosmo/batch/dispatcher.rb +86 -0
  13. data/lib/cosmo/batch.rb +164 -0
  14. data/lib/cosmo/cli.rb +31 -25
  15. data/lib/cosmo/config.rb +10 -2
  16. data/lib/cosmo/job/data.rb +14 -8
  17. data/lib/cosmo/job/processor.rb +78 -18
  18. data/lib/cosmo/job.rb +46 -9
  19. data/lib/cosmo/processor.rb +7 -7
  20. data/lib/cosmo/utils/duration.rb +33 -0
  21. data/lib/cosmo/utils.rb +1 -0
  22. data/lib/cosmo/version.rb +1 -1
  23. data/lib/cosmo/web/assets/app.css +9 -1
  24. data/lib/cosmo/web/controllers/batches.rb +21 -0
  25. data/lib/cosmo/web/controllers/jobs.rb +7 -4
  26. data/lib/cosmo/web/views/batches/_table.erb +47 -0
  27. data/lib/cosmo/web/views/batches/index.erb +10 -0
  28. data/lib/cosmo/web/views/jobs/_enqueued.erb +37 -35
  29. data/lib/cosmo/web/views/jobs/_tabs.erb +2 -0
  30. data/lib/cosmo/web/views/layout.erb +1 -1
  31. data/lib/cosmo/web.rb +3 -0
  32. data/lib/cosmo.rb +1 -0
  33. data/sig/cosmo/active_job/executor.rbs +2 -0
  34. data/sig/cosmo/api/batch.rbs +43 -0
  35. data/sig/cosmo/batch/callback.rbs +9 -0
  36. data/sig/cosmo/batch/dispatcher.rbs +23 -0
  37. data/sig/cosmo/batch.rbs +49 -0
  38. data/sig/cosmo/config.rbs +2 -0
  39. data/sig/cosmo/job/data.rbs +3 -1
  40. data/sig/cosmo/job/processor.rbs +17 -1
  41. data/sig/cosmo/job.rbs +10 -0
  42. data/sig/cosmo/processor.rbs +3 -3
  43. data/sig/cosmo/utils/duration.rbs +11 -0
  44. 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
- load_config(flags)
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].to_i * NANO if c[:max_age]
39
- c[:duplicate_window] = c[:duplicate_window].to_i * NANO if 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.
@@ -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
- jid: jid,
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? ? DEFAULTS[:retry] : @options[:retry]
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
@@ -42,9 +42,14 @@ module Cosmo
42
42
  client.publish(subject, message.data, headers: headers)
43
43
  message.ack
44
44
  else
45
- delay_ns = (execute_at - now) * 1_000_000_000
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.new.tap { |w| w.jid = data[:jid] }
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] * Config::NANO)
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
- max_retries = data[:retry].to_i + 1
132
-
133
- if current_attempt < max_retries
134
- # NATS will auto-retry with delay (exponential backoff based on current attempt).
135
- # When max_deliver is reached, NATS stops redelivering the message and marks it as "max deliveries exceeded".
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
- consumer_name = "consumer-#{stream_name}"
151
- subscription = client.subscribe(config[:subject], consumer_name, config.except(:subject, :priority, :stream, :batch_size))
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] :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:
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]" hard execution timeout in seconds. The job thread is
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]" caps how many instances run at once across all
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]" seconds to wait before NATS redelivers a job that was
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"
@@ -2,9 +2,9 @@
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)
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).to_f
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).to_f
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).to_f
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
@@ -5,6 +5,7 @@ require "cosmo/utils/json"
5
5
  require "cosmo/utils/string"
6
6
  require "cosmo/utils/signal"
7
7
  require "cosmo/utils/warnings"
8
+ require "cosmo/utils/duration"
8
9
  require "cosmo/utils/stopwatch"
9
10
  require "cosmo/utils/thread_pool"
10
11
  require "cosmo/utils/ttl_cache"
data/lib/cosmo/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cosmo
4
- VERSION = "0.4.2"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -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
- stream = API::Stream.new(stream_name)
80
- total = stream.total
81
- jobs = stream.messages(page:, limit:)
82
- total_pages = (total.to_f / limit).ceil
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">&mdash;</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&hellip;</div>
9
+ </div>
10
+ </section>