cosmonats 0.4.0 → 0.4.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f94d9b1e192b098e1bcb3ce77998d7b0ed9aca359af41fefe2a572624fa6b91
4
- data.tar.gz: 12bba3015a4beb335efae8dfe5fc178a6c467d203eaf61f922bf1b3091b67ce6
3
+ metadata.gz: 0bb0db4ef3d9dac8fe723ab7440ccc6b2fcd3d5c746efd2cb3dd4c5a28c2d6f6
4
+ data.tar.gz: 06b65ca804dda9a66bc239feefb54cb9bdad2c23d63a0e514c97c33f602616f7
5
5
  SHA512:
6
- metadata.gz: e24c844b4f17aa7eaffceebdd6b847858855fc0b5526b5537832893fd144e71f427ae352700adff3c5b39ed5cd04c89bc84007d01548ff97ba701a45e8c836b9
7
- data.tar.gz: d1a7d5e0d45c5f9b03388259b45a5a6ce659dd39378a0f13e8f31cf36d7f969a332076cada74b564f2fd7d504c646df0715778679d0e67743a5f0ef08398e7c9
6
+ metadata.gz: 0525004e2df9b1d37488e698a83ce5decf8681a673e6d66e720dc74788c78f7ebd16abd63510f830a62a8efbdad09bcc06278747e8423bce892bcaa9663f43ad
7
+ data.tar.gz: b1b522586095c52f72631c80f1e6d8f22fc8853450515ecf49c91469bed0cd2269273cb0a2de95f46fd8c6e18d9a71e3ee7b5c9fad25cc60bf16cd2a62d2c1c2
@@ -45,21 +45,24 @@ module Cosmo
45
45
 
46
46
  def retries
47
47
  client.list_consumers(name).sum { _1["num_redelivered"].to_i }
48
+ rescue NATS::Error
49
+ 0
48
50
  end
49
51
 
50
52
  def each
51
53
  return if total.zero?
52
54
 
53
- state = info[:state]
54
- current = @offset || state.first_seq.to_i
55
- last = state.last_seq.to_i
55
+ candidates = {}
56
+ current, last, subjects = scan_range
56
57
 
57
58
  loop do
58
59
  break if current > last
59
60
 
60
- job = message(current)
61
- current += 1
62
- next unless job
61
+ subject, job = next_candidate(subjects, candidates, current)
62
+ break unless job
63
+
64
+ candidates.delete(subject)
65
+ current = job.seq.to_i + 1
63
66
 
64
67
  yield job
65
68
  end
@@ -120,6 +123,33 @@ module Cosmo
120
123
 
121
124
  private
122
125
 
126
+ def scan_range
127
+ data = info
128
+ state = data[:state]
129
+ current = @offset || state.first_seq.to_i
130
+ subjects = Array(data[:config].subjects).reject { _1.start_with?(Cron::Entry::SUBJECT_PREFIX) }
131
+ [current, state.last_seq.to_i, subjects]
132
+ end
133
+
134
+ # Lowest-seq message at or after current across all job subject filters,
135
+ # caching each subject's next candidate so it isn't re-queried every step.
136
+ def next_candidate(subjects, candidates, current)
137
+ subjects.each do |subject|
138
+ next if candidates.key?(subject)
139
+
140
+ candidates[subject] = next_message(subject, current)
141
+ end
142
+
143
+ candidates.compact.min_by { |_, msg| msg.seq.to_i }
144
+ end
145
+
146
+ # Jump straight to the next message on the subject after seq, skipping any acked/deleted gaps
147
+ def next_message(subject, seq)
148
+ Job.new(name, client.get_message(name, next: true, seq: seq, subject: subject, direct: true))
149
+ rescue NATS::JetStream::Error::NotFound
150
+ nil
151
+ end
152
+
123
153
  def client
124
154
  self.class.client
125
155
  end
data/lib/cosmo/cli.rb CHANGED
@@ -124,7 +124,7 @@ module Cosmo
124
124
  class_name = entry.delete(:class)
125
125
  API::Cron.instance.upsert!(**entry, name: name, class_name: class_name)
126
126
  sum + 1
127
- end
127
+ end.to_i
128
128
 
129
129
  puts "Cron sync complete: #{schedules} schedule(s) registered" unless schedules.zero?
130
130
  puts "Cosmo streams#{" and cron schedules" unless schedules.zero?} set up successfully."
data/lib/cosmo/client.rb CHANGED
@@ -69,6 +69,8 @@ module Cosmo
69
69
  return [] if data.nil? || data["streams"].nil?
70
70
 
71
71
  data["streams"]
72
+ rescue NATS::Error
73
+ []
72
74
  end
73
75
 
74
76
  def pause_stream(name)
@@ -101,8 +103,8 @@ module Cosmo
101
103
  js.consumer_info(stream_name, consumer_name)
102
104
  end
103
105
 
104
- def get_message(name, **options)
105
- js.get_msg(name, **options)
106
+ def get_message(stream_name, **options)
107
+ js.get_msg(stream_name, **options)
106
108
  end
107
109
 
108
110
  def delete_message(name, seq)
@@ -79,13 +79,10 @@ module Cosmo
79
79
  sw = stopwatch
80
80
  Logger.with(jid: data[:jid])
81
81
  Logger.info "start"
82
- instance = worker_class.new
83
- instance.jid = data[:jid]
84
- if duration
85
- Timeout.timeout(duration) { instance.perform(*data[:args]) }
86
- else
87
- instance.perform(*data[:args])
88
- end
82
+
83
+ instance = worker_class.new.tap { |w| w.jid = data[:jid] }
84
+ perform_job(instance, data: data, message: message, duration: duration)
85
+
89
86
  message.ack
90
87
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "done" }
91
88
  true
@@ -190,6 +187,21 @@ module Cosmo
190
187
  API::Counter.instance.with(&block)
191
188
  end
192
189
  end
190
+
191
+ # @param job_instance [Cosmo::Job]
192
+ # @param data [Hash]
193
+ # @param message [NATS::Msg]
194
+ # @param duration [Float, nil]
195
+ #
196
+ # rubocop:disable Lint/UnusedMethodArgument
197
+ def perform_job(job_instance, data:, message:, duration: nil)
198
+ if duration
199
+ Timeout.timeout(duration) { job_instance.perform(*data[:args]) }
200
+ else
201
+ job_instance.perform(*data[:args])
202
+ end
203
+ end
204
+ # rubocop:enable Lint/UnusedMethodArgument
193
205
  end
194
206
  end
195
207
  end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/job/processor"
4
+ require "cosmo/sentry/job_processor_middleware"
5
+
6
+ Cosmo::Job::Processor.prepend Cosmo::Sentry::JobProcessorMiddleware
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
4
+ module Cosmo
5
+ module Sentry
6
+ module JobProcessorMiddleware
7
+ NAME_PREFIX = "Cosmonats"
8
+ OP_NAME = "queue.cosmonats"
9
+ SPAN_ORIGIN = "auto.queue.cosmonats"
10
+ STATUS_OK = 200
11
+ STATUS_FAIL = 500
12
+
13
+ # @param job_instance [Cosmo::Job]
14
+ # @param data [Hash]
15
+ # @param message [NATS::Msg]
16
+ # @param duration [Float, nil]
17
+ def perform_job(job_instance, data:, message:, duration: nil)
18
+ unless ::Sentry.initialized?
19
+ super
20
+ return
21
+ end
22
+
23
+ scope = ::Sentry.get_current_scope
24
+ transaction_name = "#{NAME_PREFIX}/#{job_instance.class.name}"
25
+ scope.set_transaction_name(transaction_name, source: :task)
26
+
27
+ transaction = ::Sentry.start_transaction(
28
+ name: scope.transaction_name,
29
+ source: scope.transaction_source,
30
+ op: OP_NAME,
31
+ origin: SPAN_ORIGIN
32
+ )
33
+ transaction&.set_data("messaging.message.id", data[:jid])
34
+ transaction&.set_data("messaging.destination.name", "#{message.metadata.stream}:#{message.subject}")
35
+ transaction&.set_data("messaging.message.retry.count", data[:retry] || 0)
36
+
37
+ begin
38
+ super
39
+
40
+ transaction&.set_http_status(STATUS_OK)
41
+ transaction&.finish
42
+ rescue StandardError => e
43
+ ::Sentry.capture_exception(
44
+ e,
45
+ contexts: {
46
+ cosmonats: data.merge(
47
+ nats_stream: message.metadata.stream,
48
+ nats_subject: message.subject,
49
+ timeout_duration: duration
50
+ )
51
+ },
52
+ hint: {
53
+ background: true,
54
+ integration: "cosmonats"
55
+ }
56
+ )
57
+ transaction&.set_http_status(STATUS_FAIL)
58
+ transaction&.finish
59
+
60
+ raise e
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
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.0"
4
+ VERSION = "0.4.1"
5
5
  end
@@ -509,6 +509,23 @@ details code { display: block; max-width: 400px; white-space: pre-wrap; }
509
509
  .htmx-request .htmx-indicator,
510
510
  .htmx-request.htmx-indicator { opacity: 1; }
511
511
 
512
+ #global-spinner {
513
+ position: fixed;
514
+ top: var(--space-2x);
515
+ right: var(--space-2x);
516
+ width: 24px;
517
+ height: 24px;
518
+ border: 3px solid var(--color-border);
519
+ border-top-color: var(--color-primary);
520
+ border-radius: 50%;
521
+ animation: spin 0.6s linear infinite;
522
+ z-index: 100;
523
+ }
524
+
525
+ @keyframes spin {
526
+ to { transform: rotate(360deg); }
527
+ }
528
+
512
529
  /* ── Responsive ────────────────────────────────────────────────────────── */
513
530
  @media (max-width: 768px) {
514
531
  .nav { flex-wrap: wrap; }
@@ -5,6 +5,7 @@
5
5
  hx-target="#content"
6
6
  hx-swap="innerHTML"
7
7
  hx-push-url="true"
8
+ hx-indicator="#global-spinner"
8
9
  class="btn <%= "btn-primary" if @stream_name.to_s == stream_name.to_s %>">
9
10
  <%= h(stream_name) %>
10
11
  </button>
@@ -66,6 +67,7 @@
66
67
  <a hx-get="<%= url_for('/jobs/_enqueued', stream_name: @stream_name, page: @page - 1, limit: @limit) %>"
67
68
  hx-target="#enqueued-poller"
68
69
  hx-swap="outerHTML"
70
+ hx-indicator="#global-spinner"
69
71
  class="btn">&#x2190; Prev</a>
70
72
  <% else -%>
71
73
  <span class="btn btn-disabled">&#x2190; Prev</span>
@@ -77,6 +79,7 @@
77
79
  <a hx-get="<%= url_for('/jobs/_enqueued', stream_name: @stream_name, page: @page + 1, limit: @limit) %>"
78
80
  hx-target="#enqueued-poller"
79
81
  hx-swap="outerHTML"
82
+ hx-indicator="#global-spinner"
80
83
  class="btn">Next &#x2192;</a>
81
84
  <% else -%>
82
85
  <span class="btn btn-disabled">Next &#x2192;</span>
@@ -19,7 +19,8 @@
19
19
  <a href="<%= url_for('/jobs/busy') %>"
20
20
  hx-get="<%= url_for('/jobs/busy') %>"
21
21
  hx-target="#content"
22
- hx-push-url="true">&#x23F3; Busy</a>
22
+ hx-push-url="true"
23
+ hx-indicator="#global-spinner">&#x23F3; Busy</a>
23
24
  </p>
24
25
  </article>
25
26
  <article class="stat-card">
@@ -29,6 +30,7 @@
29
30
  hx-get="<%= url_for('/jobs/enqueued') %>"
30
31
  hx-target="#content"
31
32
  hx-push-url="true"
33
+ hx-indicator="#global-spinner"
32
34
  class="<%= 'active' if referrer?('/jobs/enqueued') %>">&#x1F4EC; Enqueued</a>
33
35
  </p>
34
36
  </article>
@@ -39,6 +41,7 @@
39
41
  hx-get="<%= url_for('/jobs/scheduled') %>"
40
42
  hx-target="#content"
41
43
  hx-push-url="true"
44
+ hx-indicator="#global-spinner"
42
45
  class="<%= 'active' if referrer?('/jobs/scheduled') %>">&#x23F0; Scheduled</a>
43
46
  </p>
44
47
  </article>
@@ -49,6 +52,7 @@
49
52
  hx-get="<%= url_for('/jobs/dead') %>"
50
53
  hx-target="#content"
51
54
  hx-push-url="true"
55
+ hx-indicator="#global-spinner"
52
56
  class="<%= 'active' if referrer?('/jobs/dead') %>">&#x1F480; Dead</a>
53
57
  </p>
54
58
  </article>
@@ -28,6 +28,7 @@
28
28
  </header>
29
29
  <main class="container">
30
30
  <%= content_for :view %>
31
+ <div id="global-spinner" class="htmx-indicator" aria-hidden="true"></div>
31
32
  </main>
32
33
  </body>
33
34
  </html>
@@ -44,6 +44,12 @@ module Cosmo
44
44
 
45
45
  private
46
46
 
47
+ def scan_range: () -> [::Integer, ::Integer, Array[::String]]
48
+
49
+ def next_candidate: (Array[::String] subjects, Hash[::String, Job?] candidates, ::Integer current) -> [::String, Job]?
50
+
51
+ def next_message: (::String subject, ::Integer seq) -> Job?
52
+
47
53
  def client: () -> Client
48
54
  end
49
55
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cosmonats
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Vorotilin
@@ -88,6 +88,8 @@ files:
88
88
  - lib/cosmo/processor.rb
89
89
  - lib/cosmo/publisher.rb
90
90
  - lib/cosmo/railtie.rb
91
+ - lib/cosmo/sentry/auto.rb
92
+ - lib/cosmo/sentry/job_processor_middleware.rb
91
93
  - lib/cosmo/stream.rb
92
94
  - lib/cosmo/stream/data.rb
93
95
  - lib/cosmo/stream/message.rb
@@ -197,7 +199,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
197
199
  - !ruby/object:Gem::Version
198
200
  version: '0'
199
201
  requirements: []
200
- rubygems_version: 4.0.12
202
+ rubygems_version: 4.0.16
201
203
  specification_version: 4
202
204
  summary: Lightweight background and stream processing
203
205
  test_files: []