cosmonats 0.6.0 → 0.8.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +21 -169
  3. data/README.md +296 -116
  4. data/lib/cosmo/active_job/adapter.rb +1 -2
  5. data/lib/cosmo/api/counter.rb +18 -15
  6. data/lib/cosmo/api/cron/entry.rb +23 -3
  7. data/lib/cosmo/api/cron.rb +25 -26
  8. data/lib/cosmo/api/job.rb +30 -0
  9. data/lib/cosmo/api/kv.rb +31 -11
  10. data/lib/cosmo/api/registry.rb +68 -0
  11. data/lib/cosmo/api/stats/busy.rb +63 -0
  12. data/lib/cosmo/api/stats/metrics.rb +109 -0
  13. data/lib/cosmo/api/stats/processes.rb +47 -0
  14. data/lib/cosmo/api/stats/totals.rb +27 -0
  15. data/lib/cosmo/api/stats.rb +8 -6
  16. data/lib/cosmo/api/stream.rb +1 -1
  17. data/lib/cosmo/api.rb +3 -2
  18. data/lib/cosmo/batch/counters.rb +19 -0
  19. data/lib/cosmo/batch.rb +15 -8
  20. data/lib/cosmo/cli.rb +46 -10
  21. data/lib/cosmo/client.rb +61 -7
  22. data/lib/cosmo/config/cosmo.yml +58 -0
  23. data/lib/cosmo/config/settings.rb +20 -0
  24. data/lib/cosmo/config.rb +144 -15
  25. data/lib/cosmo/engine.rb +103 -4
  26. data/lib/cosmo/heartbeat.rb +140 -0
  27. data/lib/cosmo/http_server/handler.rb +44 -0
  28. data/lib/cosmo/http_server/health.rb +39 -0
  29. data/lib/cosmo/http_server/ping.rb +23 -0
  30. data/lib/cosmo/http_server.rb +62 -0
  31. data/lib/cosmo/job/data.rb +17 -12
  32. data/lib/cosmo/job/enqueuer.rb +53 -0
  33. data/lib/cosmo/job/failure.rb +52 -0
  34. data/lib/cosmo/job/limit.rb +2 -2
  35. data/lib/cosmo/job/processor.rb +110 -73
  36. data/lib/cosmo/job/requeue.rb +20 -0
  37. data/lib/cosmo/job/stream_filter.rb +54 -0
  38. data/lib/cosmo/job.rb +10 -22
  39. data/lib/cosmo/logger.rb +15 -2
  40. data/lib/cosmo/middleware/busy.rb +15 -0
  41. data/lib/cosmo/middleware/chain.rb +137 -0
  42. data/lib/cosmo/middleware/limit.rb +50 -0
  43. data/lib/cosmo/middleware/metrics.rb +39 -0
  44. data/lib/cosmo/middleware/sentry.rb +61 -0
  45. data/lib/cosmo/middleware/totals.rb +29 -0
  46. data/lib/cosmo/middleware.rb +7 -0
  47. data/lib/cosmo/processor.rb +49 -8
  48. data/lib/cosmo/publisher.rb +1 -13
  49. data/lib/cosmo/railtie.rb +2 -2
  50. data/lib/cosmo/services.rb +69 -0
  51. data/lib/cosmo/stream/message.rb +2 -1
  52. data/lib/cosmo/stream/processor.rb +12 -1
  53. data/lib/cosmo/utils/hash.rb +7 -8
  54. data/lib/cosmo/utils/json.rb +1 -1
  55. data/lib/cosmo/utils/overrides.rb +16 -10
  56. data/lib/cosmo/utils/thread_pool.rb +5 -0
  57. data/lib/cosmo/version.rb +1 -1
  58. data/lib/cosmo/web/assets/app.css +79 -0
  59. data/lib/cosmo/web/chart.rb +98 -0
  60. data/lib/cosmo/web/controllers/crons.rb +8 -0
  61. data/lib/cosmo/web/controllers/jobs.rb +43 -5
  62. data/lib/cosmo/web/controllers/metrics.rb +48 -0
  63. data/lib/cosmo/web/helpers/application.rb +20 -1
  64. data/lib/cosmo/web/views/batches/_table.erb +11 -0
  65. data/lib/cosmo/web/views/batches/index.erb +1 -1
  66. data/lib/cosmo/web/views/jobs/_busy.erb +70 -4
  67. data/lib/cosmo/web/views/jobs/_dead.erb +29 -16
  68. data/lib/cosmo/web/views/jobs/_enqueued.erb +10 -0
  69. data/lib/cosmo/web/views/jobs/_processes.erb +145 -0
  70. data/lib/cosmo/web/views/jobs/_scheduled.erb +1 -1
  71. data/lib/cosmo/web/views/jobs/_stats.erb +4 -0
  72. data/lib/cosmo/web/views/jobs/_tabs.erb +6 -0
  73. data/lib/cosmo/web/views/jobs/busy.erb +1 -1
  74. data/lib/cosmo/web/views/jobs/dead.erb +2 -2
  75. data/lib/cosmo/web/views/jobs/enqueued.erb +1 -1
  76. data/lib/cosmo/web/views/jobs/index.erb +7 -1
  77. data/lib/cosmo/web/views/jobs/scheduled.erb +1 -1
  78. data/lib/cosmo/web/views/layout.erb +1 -1
  79. data/lib/cosmo/web/views/metrics/_chart.erb +36 -0
  80. data/lib/cosmo/web/views/metrics/_panel.erb +13 -0
  81. data/lib/cosmo/web/views/metrics/_table.erb +41 -0
  82. data/lib/cosmo/web/views/metrics/index.erb +17 -0
  83. data/lib/cosmo/web/views/streams/index.erb +1 -1
  84. data/lib/cosmo/web.rb +5 -0
  85. data/lib/cosmo.rb +51 -0
  86. data/sig/cosmo/api/counter.rbs +3 -6
  87. data/sig/cosmo/api/cron/entry.rbs +2 -0
  88. data/sig/cosmo/api/cron.rbs +4 -2
  89. data/sig/cosmo/api/job.rbs +12 -0
  90. data/sig/cosmo/api/kv.rbs +7 -1
  91. data/sig/cosmo/api/registry.rbs +30 -0
  92. data/sig/cosmo/api/stats/busy.rbs +31 -0
  93. data/sig/cosmo/api/stats/metrics.rbs +42 -0
  94. data/sig/cosmo/api/stats/processes.rbs +19 -0
  95. data/sig/cosmo/api/stats/totals.rbs +18 -0
  96. data/sig/cosmo/batch/counters.rbs +10 -0
  97. data/sig/cosmo/batch.rbs +3 -4
  98. data/sig/cosmo/cli.rbs +6 -0
  99. data/sig/cosmo/client.rbs +15 -2
  100. data/sig/cosmo/config/settings.rbs +31 -0
  101. data/sig/cosmo/config.rbs +59 -5
  102. data/sig/cosmo/engine.rbs +29 -0
  103. data/sig/cosmo/heartbeat.rbs +38 -0
  104. data/sig/cosmo/http_server/handler.rbs +17 -0
  105. data/sig/cosmo/http_server/health.rbs +21 -0
  106. data/sig/cosmo/http_server/ping.rbs +14 -0
  107. data/sig/cosmo/http_server.rbs +22 -0
  108. data/sig/cosmo/job/data.rbs +5 -3
  109. data/sig/cosmo/job/enqueuer.rbs +11 -0
  110. data/sig/cosmo/job/failure.rbs +28 -0
  111. data/sig/cosmo/job/processor.rbs +18 -4
  112. data/sig/cosmo/job/requeue.rbs +9 -0
  113. data/sig/cosmo/job/stream_filter.rbs +25 -0
  114. data/sig/cosmo/job.rbs +0 -2
  115. data/sig/cosmo/logger.rbs +6 -0
  116. data/sig/cosmo/middleware/chain.rbs +51 -0
  117. data/sig/cosmo/middleware/sentry.rbs +14 -0
  118. data/sig/cosmo/middleware.rbs +35 -0
  119. data/sig/cosmo/processor.rbs +14 -1
  120. data/sig/cosmo/publisher.rbs +1 -9
  121. data/sig/cosmo/services.rbs +18 -0
  122. data/sig/cosmo/stream/message.rbs +3 -1
  123. data/sig/cosmo/stream/processor.rbs +4 -0
  124. data/sig/cosmo/utils/hash.rbs +2 -3
  125. data/sig/cosmo/utils/thread_pool.rbs +3 -0
  126. data/sig/cosmo.rbs +15 -0
  127. metadata +62 -22
  128. data/lib/cosmo/api/busy.rb +0 -66
  129. data/lib/cosmo/sentry/auto.rb +0 -6
  130. data/lib/cosmo/sentry/job_processor_middleware.rb +0 -66
  131. data/sig/cosmo/api/busy.rbs +0 -35
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module Middleware
5
+ # Records every execution for the web UI's Metrics tab while +config.metrics+ is enabled: its run time when it
6
+ # succeeds, a failure otherwise, and the wait from enqueue to the job's first delivery. ActiveJob jobs count under
7
+ # their own class.
8
+ class Metrics
9
+ # @param job [Cosmo::Job]
10
+ # @param data [Hash]
11
+ # @param _message [NATS::Msg]
12
+ def call(job, data, _message) # rubocop:disable Metrics/AbcSize
13
+ return yield unless Config.metrics.enabled
14
+
15
+ name = job_name(job, data)
16
+ wait_ms = (Time.now - job.enqueued_at) * 1000 if job.attempt == 1 && job.enqueued_at
17
+ started = clock
18
+ result = yield
19
+ API::Stats::Metrics.instance.record(name, exec_ms: clock - started, wait_ms:)
20
+ result
21
+ rescue Exception # rubocop:disable Lint/RescueException
22
+ API::Stats::Metrics.instance.record(name, wait_ms:, failed: true) if name
23
+ raise
24
+ end
25
+
26
+ private
27
+
28
+ def job_name(job, data)
29
+ return job.class.name unless defined?(ActiveJobAdapter::Executor) && job.is_a?(ActiveJobAdapter::Executor)
30
+
31
+ data.dig(:args, 0, :job_class).to_s
32
+ end
33
+
34
+ def clock
35
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize
4
+ module Cosmo
5
+ module Middleware
6
+ # Wraps every job execution in a Sentry transaction. Errors reach Sentry through {ERROR_HANDLER}, which also
7
+ # captures those outside jobs (stream batches, fetches, the scheduler). Needs +sentry-ruby+, so it is not loaded
8
+ # by default:
9
+ #
10
+ # require "cosmo/middleware/sentry"
11
+ #
12
+ # Cosmo.configure do |config|
13
+ # config.server_middleware { |chain| chain.add Cosmo::Middleware::Sentry }
14
+ # config.error_handlers << Cosmo::Middleware::Sentry::ERROR_HANDLER
15
+ # end
16
+ class Sentry
17
+ NAME_PREFIX = "Cosmonats"
18
+ OP_NAME = "queue.cosmonats"
19
+ SPAN_ORIGIN = "auto.queue.cosmonats"
20
+ STATUS_OK = 200
21
+ STATUS_FAIL = 500
22
+
23
+ ERROR_HANDLER = lambda do |error, context|
24
+ next unless ::Sentry.initialized?
25
+
26
+ ::Sentry.capture_exception(error, contexts: { cosmonats: context }, hint: { background: true, integration: "cosmonats" })
27
+ end
28
+
29
+ # @param job [Cosmo::Job]
30
+ # @param data [Hash]
31
+ # @param message [NATS::Msg]
32
+ def call(job, data, message)
33
+ return yield unless ::Sentry.initialized?
34
+
35
+ scope = ::Sentry.get_current_scope
36
+ transaction_name = "#{NAME_PREFIX}/#{job.class.name}"
37
+ scope.set_transaction_name(transaction_name, source: :task)
38
+ transaction = ::Sentry.start_transaction(
39
+ name: scope.transaction_name,
40
+ source: scope.transaction_source,
41
+ op: OP_NAME,
42
+ origin: SPAN_ORIGIN
43
+ )
44
+ transaction&.set_data("messaging.message.id", data[:jid])
45
+ transaction&.set_data("messaging.destination.name", "#{message.metadata.stream}:#{message.subject}")
46
+ transaction&.set_data("messaging.message.retry.count", data[:retry] || 0)
47
+
48
+ begin
49
+ result = yield
50
+ transaction&.set_http_status(STATUS_OK)
51
+ transaction&.finish
52
+ result
53
+ rescue StandardError
54
+ transaction&.set_http_status(STATUS_FAIL)
55
+ transaction&.finish
56
+ raise
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module Middleware
5
+ # Counts every execution as processed or failed, so a job retried twice and then succeeding adds two failures
6
+ # and one processed. A counter that cannot be written is logged rather than raised, so stats never fail a job.
7
+ class Totals
8
+ # @param _job [Cosmo::Job]
9
+ # @param _data [Hash]
10
+ # @param _message [NATS::Msg]
11
+ def call(_job, _data, _message)
12
+ result = yield
13
+ increment(:processed)
14
+ result
15
+ rescue Exception # rubocop:disable Lint/RescueException
16
+ increment(:failed)
17
+ raise
18
+ end
19
+
20
+ private
21
+
22
+ def increment(key)
23
+ API::Stats::Totals.instance.increment(key)
24
+ rescue StandardError => e
25
+ Logger.debug "Totals #{key} counter error: #{e.class} #{e.message}"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/middleware/chain"
4
+ require "cosmo/middleware/limit"
5
+ require "cosmo/middleware/busy"
6
+ require "cosmo/middleware/totals"
7
+ require "cosmo/middleware/metrics"
@@ -5,20 +5,29 @@ module Cosmo
5
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
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
7
  STREAM_EMPTY_BACKOFF_MAX = "5s" # Max sleep between empty fetches (override via COSMO_STREAM_EMPTY_BACKOFF_MAX)
8
+ QUIET_SLEEP = "5s" # How long to sleep between checks while in quiet mode (override via COSMO_QUIET_IDLE_SLEEP)
8
9
 
9
10
  def self.run(...)
10
11
  new(...).tap(&:run)
11
12
  end
12
13
 
14
+ # Checks the command-line options this processor understands, before anything is booted or connected.
15
+ #
16
+ # @param options [Hash]
17
+ # @return [void]
18
+ def self.validate_options!(_options); end
19
+
13
20
  attr_reader :consumers
14
21
 
15
- def initialize(pool, running, options)
22
+ def initialize(pool, running, options, quiet:)
16
23
  @pool = pool
17
24
  @running = running
25
+ @quiet = quiet
18
26
  @options = options
19
27
  @threads = []
20
28
  @consumers = []
21
29
  @cache = Utils::TTLCache.new
30
+ @busy = Concurrent::AtomicFixnum.new
22
31
  end
23
32
 
24
33
  def run
@@ -29,6 +38,13 @@ module Cosmo
29
38
  run_loop
30
39
  end
31
40
 
41
+ # Threads processing fetched messages right now; threads waiting on a fetch don't count.
42
+ #
43
+ # @return [Integer]
44
+ def busy
45
+ @busy.value
46
+ end
47
+
32
48
  def stop(timeout = Config[:timeout])
33
49
  @running.make_false
34
50
  @pool.shutdown
@@ -52,6 +68,11 @@ module Cosmo
52
68
  while running?
53
69
  break if shutdown
54
70
 
71
+ if quiet?
72
+ sleep(Utils::Duration.parse(ENV.fetch("COSMO_QUIET_IDLE_SLEEP", QUIET_SLEEP)))
73
+ next
74
+ end
75
+
55
76
  all_empty = true # every stream is empty
56
77
  all_paused = true # every stream is paused
57
78
  consumers.each do |(subscription, config, processor)| # rubocop:disable Metrics/BlockLength
@@ -65,9 +86,10 @@ module Cosmo
65
86
  end
66
87
  all_paused = false
67
88
 
68
- _, skip_t = consumer_state[stream_name]
89
+ key = backoff_key(config)
90
+ _, skip_t = consumer_state[key]
69
91
  if skip_t && Time.now < skip_t
70
- Logger.trace "stream #{stream_name} is empty, backing off"
92
+ Logger.trace "#{key} is empty, backing off"
71
93
  next
72
94
  end
73
95
  all_empty = false
@@ -75,20 +97,23 @@ module Cosmo
75
97
  begin
76
98
  @pool.post do
77
99
  # Re-check, after possibly being blocked on post to thread pool
78
- _, skip_t = consumer_state[stream_name]
100
+ _, skip_t = consumer_state[key]
79
101
  next if skip_t && Time.now < skip_t
80
102
 
81
103
  timeout = fetch_timeout(config)
82
104
  Logger.trace "fetching #{fetch_subjects(config).inspect}, timeout=#{timeout}"
105
+ started_at = Time.now
83
106
  messages = fetch(subscription, batch_size: config[:batch_size], timeout:)
84
107
  Logger.trace "fetched (#{messages&.size.to_i}) messages"
85
108
  if messages&.any?
86
- consumer_state.delete(stream_name)
87
- process(messages, processor)
109
+ consumer_state.delete(key)
110
+ track_busy { process(messages, processor) }
88
111
  else
89
112
  max_backoff = Utils::Duration.parse(ENV.fetch("COSMO_STREAM_EMPTY_BACKOFF_MAX", STREAM_EMPTY_BACKOFF_MAX))
90
- consumer_state.compute(stream_name) do |current|
91
- count = (current&.first || 0) + 1
113
+ consumer_state.compute(key) do |(count, wake_at)|
114
+ next [count, wake_at] if wake_at && wake_at > started_at
115
+
116
+ count = count.to_i + 1
92
117
  backoff = [timeout * (2**(count - 1)), max_backoff].min
93
118
  [count, Time.now + backoff]
94
119
  end
@@ -129,10 +154,21 @@ module Cosmo
129
154
  raise NotImplementedError
130
155
  end
131
156
 
157
+ def track_busy
158
+ @busy.increment
159
+ yield
160
+ ensure
161
+ @busy.decrement
162
+ end
163
+
132
164
  def running?
133
165
  @running.true?
134
166
  end
135
167
 
168
+ def quiet?
169
+ @quiet.true?
170
+ end
171
+
136
172
  def scheduler?
137
173
  false
138
174
  end
@@ -144,6 +180,7 @@ module Cosmo
144
180
  rescue StandardError => e
145
181
  Logger.error "Snap! Error just happened"
146
182
  Logger.error "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
183
+ Cosmo.handle_error(e, { source: :fetch })
147
184
 
148
185
  backoff = ENV.fetch("COSMO_STREAMS_FETCH_BACKOFF", 5).to_f
149
186
  sleep([timeout, backoff].max) # backoff before retry
@@ -161,5 +198,9 @@ module Cosmo
161
198
  def consumer_state
162
199
  @consumer_state ||= Concurrent::Map.new
163
200
  end
201
+
202
+ def backoff_key(config)
203
+ config[:stream].to_s
204
+ end
164
205
  end
165
206
  end
@@ -7,7 +7,7 @@ module Cosmo
7
7
  class << self
8
8
  extend Forwardable
9
9
 
10
- delegate %i[publish publish_job publish_batch] => :instance
10
+ delegate %i[publish] => :instance
11
11
  end
12
12
 
13
13
  def self.instance
@@ -22,17 +22,5 @@ module Cosmo
22
22
  payload = (serializer || Stream::Serializer).serialize(data)
23
23
  @client.publish(subject, payload, **options)
24
24
  end
25
-
26
- def publish_job(data)
27
- subject, payload, params = data.to_args
28
- @client.publish(subject, payload, **params)
29
- data.jid
30
- rescue NATS::JetStream::Error::NoStreamResponse
31
- raise StreamNotFoundError, params[:stream].to_s
32
- end
33
-
34
- def publish_batch(subject, batch, **options)
35
- batch.each { publish(subject, _1, **options) }
36
- end
37
25
  end
38
26
  end
data/lib/cosmo/railtie.rb CHANGED
@@ -12,10 +12,10 @@ module Cosmo
12
12
  require "cosmo/active_job"
13
13
  end
14
14
 
15
- # Autoload config/cosmo.yml when it exists and no config has been loaded yet.
15
+ # Loads config/cosmo.yml, or the built-in defaults without one, unless a config has been loaded already.
16
16
  initializer "cosmo.load_config", after: "cosmo.active_job_adapter" do |app|
17
17
  config_path = app.root.join("config", "cosmo.yml")
18
- Config.load(config_path.to_s) if config_path.exist? && Config.instance.none?
18
+ Config.load(config_path.exist? ? config_path.to_s : nil) if Config.instance.none?
19
19
  end
20
20
  end
21
21
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ # The streams Cosmo itself depends on. They are built from the settings in Cosmo.configure, never from cosmo.yml,
5
+ # and `cosmo --setup` creates them next to the user's streams.
6
+ module Services
7
+ SCHEDULED = Job::StreamFilter::SCHEDULED.to_s
8
+ DEAD = "dead"
9
+ METADATA = { "_cosmo.type" => "jobs" }.freeze
10
+ RETENTION_CHANGE = 10_052
11
+
12
+ module_function
13
+
14
+ # Holds delayed jobs and every cron template. It alone enables NATS message scheduling, since a schedule can only
15
+ # target a subject its own stream covers, and NATS refuses +discard: new+ wherever scheduling is enabled.
16
+ #
17
+ # @return [Hash, nil] nil when scheduling is turned off
18
+ def scheduled_stream
19
+ return unless Config.scheduled.enabled
20
+
21
+ { storage: "file", retention: "workqueue", discard: "old", allow_direct: true, allow_msg_schedules: true,
22
+ duplicate_window: Config.to_ns(120), subjects: ["jobs.#{SCHEDULED}.>", "#{API::Cron::Entry::SUBJECT_PREFIX}.>"],
23
+ num_replicas: Config.replicas, description: "Scheduled jobs and cron schedules", metadata: METADATA }
24
+ end
25
+
26
+ # +max_deliver+ stays above 1: the scheduler naks a message that is not yet due, and one whose dispatch failed,
27
+ # and both need another delivery.
28
+ #
29
+ # @return [Hash, nil] nil when scheduling is turned off
30
+ def scheduled_consumer
31
+ return unless Config.scheduled.enabled
32
+
33
+ { ack_policy: "explicit", max_deliver: 5, max_ack_pending: 100, ack_wait: 10, subject: "jobs.#{SCHEDULED}.>" }
34
+ end
35
+
36
+ # @return [Hash, nil] nil when dead-lettering is turned off
37
+ def dead_stream
38
+ dead = Config.dead
39
+ return unless dead.enabled
40
+
41
+ { storage: "file", retention: "workqueue", discard: "old", allow_direct: true, duplicate_window: Config.to_ns(120),
42
+ max_age: Config.to_ns(Utils::Duration.parse(dead.max_age)), max_msgs: dead.max_msgs, max_bytes: dead.max_bytes,
43
+ subjects: ["jobs.#{DEAD}.>"], num_replicas: Config.replicas, description: "Dead jobs", metadata: METADATA }
44
+ end
45
+
46
+ # Creates or updates every enabled service stream, plus the internal totals and batch counters and, while enabled,
47
+ # the job metrics.
48
+ #
49
+ # @return [Array<String>] the names of the service streams set up, without the internal ones
50
+ def setup!
51
+ streams = { SCHEDULED => scheduled_stream, DEAD => dead_stream }.compact
52
+ streams.each { |name, config| setup_stream(name, config) }
53
+ API::Stats::Totals.setup!
54
+ Batch::Counters.setup!
55
+ API::Stats::Metrics.setup! if Config.metrics.enabled
56
+ streams.keys
57
+ end
58
+
59
+ # @raise [Error] when an existing stream has another retention policy, which NATS can't change in place
60
+ def setup_stream(name, config)
61
+ Client.instance.setup_stream(name, config)
62
+ rescue NATS::JetStream::Error::APIError => e
63
+ raise unless e.err_code == RETENTION_CHANGE
64
+
65
+ raise Error, "The existing `#{name}` stream has another retention policy, which NATS can't change in place: " \
66
+ "delete it (`nats stream rm #{name}`, dropping its messages) and rerun `cosmo --setup`"
67
+ end
68
+ end
69
+ end
@@ -7,8 +7,9 @@ module Cosmo
7
7
  class Message
8
8
  extend Forwardable
9
9
 
10
- delegate %i[subject reply header metadata ack nack term in_progress] => :@msg
10
+ delegate %i[subject reply header metadata ack nak term in_progress] => :@msg
11
11
  delegate %i[timestamp num_delivered num_pending] => :metadata
12
+ alias nack nak
12
13
 
13
14
  def initialize(msg, serializer: nil)
14
15
  @msg = msg
@@ -3,6 +3,11 @@
3
3
  module Cosmo
4
4
  module Stream
5
5
  class Processor < ::Cosmo::Processor
6
+ # @return [Array<String>] names of the stream classes being processed
7
+ def subscriptions
8
+ @consumers.map { |(_, config, _)| config[:class].name }
9
+ end
10
+
6
11
  private
7
12
 
8
13
  def setup
@@ -33,8 +38,10 @@ module Cosmo
33
38
  processor.process(messages)
34
39
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "done" }
35
40
  rescue StandardError => e
36
- Logger.debug e
41
+ Logger.error e
37
42
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
43
+ Cosmo.handle_error(e, { source: :stream, stream: metadata&.stream, processor: processor.class.name,
44
+ size: messages&.size })
38
45
  rescue Exception # rubocop:disable Lint/RescueException
39
46
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
40
47
  raise
@@ -65,6 +72,10 @@ module Cosmo
65
72
  config.dig(:consumer, :subjects)
66
73
  end
67
74
 
75
+ def backoff_key(config)
76
+ config[:consumer_name] || config[:class].name
77
+ end
78
+
68
79
  def fetch_timeout(config)
69
80
  timeout = config[:fetch_timeout].to_f
70
81
  if timeout <= 0
@@ -36,14 +36,13 @@ module Cosmo
36
36
  end
37
37
  end
38
38
 
39
- # deep set
40
- def set(hash, *keys, value)
41
- last_key = keys.pop
42
- target = keys.reduce(hash) do |base, key|
43
- base[key] ||= {}
44
- base[key]
45
- end
46
- target[last_key] = value
39
+ # Nested hashes merge recursively, +other+ winning; any other value in +other+ replaces the one in +base+.
40
+ #
41
+ # @param base [::Hash]
42
+ # @param other [::Hash]
43
+ # @return [::Hash] a new hash
44
+ def deep_merge(base, other)
45
+ base.merge(other) { |_, old, new| old.is_a?(::Hash) && new.is_a?(::Hash) ? deep_merge(old, new) : new }
47
46
  end
48
47
 
49
48
  # deep dup
@@ -8,7 +8,7 @@ module Cosmo
8
8
  module_function
9
9
 
10
10
  def parse(value, default: nil, symbolize_names: true, **options)
11
- JSON.parse(value, options.merge(symbolize_names:))
11
+ JSON.parse(value, **options, symbolize_names:)
12
12
  rescue TypeError, JSON::ParserError
13
13
  default
14
14
  end
@@ -1,20 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  Cosmo::Utils::Warnings.silence do
4
- members = NATS::JetStream::API::StreamConfig.members + %i[allow_msg_counter allow_msg_schedules]
5
- NATS::JetStream::API::StreamConfig = Struct.new(*members, keyword_init: true) do
6
- def initialize(opts = {})
7
- rem = opts.keys - members
8
- opts.delete_if { |k| rem.include?(k) }
9
- super
4
+ unless NATS::JetStream::API::StreamConfig.members.include?(:allow_msg_schedules)
5
+ members = NATS::JetStream::API::StreamConfig.members + %i[allow_msg_counter allow_msg_schedules]
6
+ NATS::JetStream::API::StreamConfig = Struct.new(*members, keyword_init: true) do
7
+ def initialize(opts = {})
8
+ rem = opts.keys - members
9
+ opts.delete_if { |k| rem.include?(k) }
10
+ super
11
+ end
10
12
  end
11
13
  end
12
14
 
13
- members = NATS::JetStream::PubAck.members + [:val]
14
- NATS::JetStream::PubAck = Struct.new(*members, keyword_init: true)
15
+ unless NATS::JetStream::PubAck.members.include?(:val)
16
+ members = NATS::JetStream::PubAck.members + [:val]
17
+ NATS::JetStream::PubAck = Struct.new(*members, keyword_init: true)
18
+ end
15
19
  end
16
20
 
17
- # Upstream bug in nats-pure 2.5.0 (https://github.com/nats-io/nats.rb):
21
+ # Upstream bug in nats-pure 2.5.0 and 2.6.0 (https://github.com/nats-io/nats-pure.rb):
18
22
  # NATS::JetStream::PullSubscription#fetch only assigns its `start_time` local in the
19
23
  # `batch > 1` branch, but the final "did we time out" check outside the case statement
20
24
  # reads it unconditionally. For `batch == 1` (what Cosmo::Processor always uses),
@@ -44,7 +48,9 @@ end
44
48
  #
45
49
  # Vendored copy of nats-pure's PullSubscription#fetch, kept as close to
46
50
  # upstream as possible (two one-line fixes, see comments above), so it's easy to diff against the
47
- # next nats-pure release and drop once fixed there.
51
+ # next nats-pure release and drop once fixed there. Fixed on nats-pure main by the fetch rewrite
52
+ # (#181), recognized by its private #take_pending, in which case this copy is not loaded.
53
+ return if NATS::JetStream.const_get(:PullSubscription).private_method_defined?(:take_pending)
48
54
 
49
55
  # rubocop:disable all
50
56
  module NATS
@@ -16,11 +16,16 @@ module Cosmo
16
16
 
17
17
  def initialize(concurrency)
18
18
  @mutex = Thread::Mutex.new
19
+ @concurrency = concurrency
19
20
  @available = concurrency
20
21
  @cond = ConditionVariable.new
21
22
  @pool = Concurrent::FixedThreadPool.new(concurrency)
22
23
  end
23
24
 
25
+ def wait_idle
26
+ @mutex.synchronize { @cond.wait(@mutex) until @available == @concurrency }
27
+ end
28
+
24
29
  def post
25
30
  @mutex.synchronize do
26
31
  @cond.wait(@mutex) while @available <= 0
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.6.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -163,6 +163,14 @@ button:hover, .btn:hover {
163
163
  );
164
164
  }
165
165
 
166
+ .btn-sm {
167
+ box-shadow: none;
168
+ font-size: var(--font-size-small);
169
+ font-weight: 600;
170
+ line-height: var(--space-2x);
171
+ padding: var(--space-1-2) var(--space);
172
+ }
173
+
166
174
  /* ── Badges ────────────────────────────────────────────────────────────── */
167
175
  .badge {
168
176
  border-radius: var(--space-1-2);
@@ -343,6 +351,12 @@ section .pending {
343
351
  align-items: center;
344
352
  }
345
353
 
354
+ section .pending .poll-status {
355
+ align-items: center;
356
+ display: inline-flex;
357
+ gap: var(--space);
358
+ }
359
+
346
360
  /* ── Stat cards ────────────────────────────────────────────────────────── */
347
361
  .cards-container {
348
362
  display: grid;
@@ -425,6 +439,15 @@ td {
425
439
  tr:last-child td { border-bottom: none; }
426
440
  tr:hover { background: var(--color-table-bg-alt); }
427
441
 
442
+ tr.group-row td,
443
+ tr.group-row:hover { background: var(--color-table-bg-alt); }
444
+ tr.group-row td {
445
+ color: var(--color-text-light);
446
+ font-size: var(--font-size-small);
447
+ font-weight: 600;
448
+ padding: var(--space) var(--space-2x);
449
+ }
450
+
428
451
  /* ── Subjects ──────────────────────────────────────────────────────────── */
429
452
  .subjects { display: flex; flex-wrap: wrap; gap: var(--space-1-2); }
430
453
 
@@ -479,6 +502,13 @@ time {
479
502
  font-size: var(--font-size-small);
480
503
  word-wrap: break-word;
481
504
  }
505
+ .error-message details code {
506
+ max-width: 600px;
507
+ max-height: 320px;
508
+ overflow: auto;
509
+ white-space: pre;
510
+ font-size: var(--font-size-small);
511
+ }
482
512
 
483
513
  /* ── Links ─────────────────────────────────────────────────────────────── */
484
514
  .stream-link { color: var(--color-primary); font-weight: 600; }
@@ -518,6 +548,15 @@ details summary {
518
548
  details summary code { display: inline; user-select: text; }
519
549
  details[open] summary { margin-bottom: var(--space); }
520
550
  details code { display: block; max-width: 400px; white-space: pre-wrap; }
551
+ details summary:has(> code.truncate) { white-space: nowrap; }
552
+ details summary code.truncate {
553
+ display: inline-block;
554
+ max-width: 32ch;
555
+ overflow: hidden;
556
+ text-overflow: ellipsis;
557
+ vertical-align: middle;
558
+ white-space: nowrap;
559
+ }
521
560
 
522
561
  /* ── HTMX ──────────────────────────────────────────────────────────────── */
523
562
  .htmx-indicator { opacity: 0; transition: opacity 200ms; }
@@ -542,6 +581,46 @@ details code { display: block; max-width: 400px; white-space: pre-wrap; }
542
581
  }
543
582
 
544
583
  /* ── Responsive ────────────────────────────────────────────────────────── */
584
+
585
+ .metrics-chart {
586
+ background: var(--color-elevated);
587
+ border-radius: var(--space-1-2);
588
+ box-shadow: 0 1px 3px var(--color-shadow);
589
+ padding: var(--space-2x) var(--space-3x);
590
+ margin-bottom: var(--space-3x);
591
+ }
592
+ .metrics-chart svg { width: 100%; height: auto; display: block; }
593
+ .metrics-chart-header {
594
+ display: flex;
595
+ justify-content: space-between;
596
+ flex-wrap: wrap;
597
+ gap: var(--space);
598
+ margin-bottom: var(--space);
599
+ }
600
+ .metrics-legend { display: flex; gap: var(--space-2x); font-size: var(--font-size-small); color: var(--color-text-light); }
601
+ .metrics-legend span::before {
602
+ content: "";
603
+ display: inline-block;
604
+ width: 10px;
605
+ height: 10px;
606
+ margin-right: 6px;
607
+ border-radius: 2px;
608
+ }
609
+ .metrics-legend-runs::before { background: var(--color-primary); opacity: 0.45; }
610
+ .metrics-legend-exec::before { background: var(--color-warning); }
611
+ .chart-grid { stroke: var(--color-border); stroke-width: 1; }
612
+ .chart-axis { fill: var(--color-text-light); font-size: 11px; font-family: var(--font-sans); }
613
+ .chart-bar { fill: var(--color-primary); opacity: 0.45; }
614
+ .chart-bar:hover { opacity: 0.7; }
615
+ .chart-line { fill: none; stroke: var(--color-warning); stroke-width: 2; }
616
+ .chart-point { fill: var(--color-warning); stroke: var(--color-elevated); stroke-width: 2; }
617
+ .metrics-hint { margin: 0 0 var(--space); font-size: var(--font-size-small); color: var(--color-text-light); }
618
+ .metrics-table tr.selectable { cursor: pointer; }
619
+ .metrics-table tr.selectable:hover { background: var(--color-selected); }
620
+ .metrics-table tr.selected { background: var(--color-selected); }
621
+ .metrics-table tr.selected td:first-child { box-shadow: inset 3px 0 0 var(--color-primary); }
622
+ .metrics-table tr.selected code { color: var(--color-primary); }
623
+
545
624
  @media (max-width: 768px) {
546
625
  .nav { flex-wrap: wrap; }
547
626
  .nav-list { order: 3; width: 100%; margin-top: var(--space); }