cosmonats 0.4.0 → 0.4.2

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: 9167e7ccc35b55db0664e863eb04b007614dfc78efb7f203706b18e58ed2593f
4
+ data.tar.gz: 3654c4f5681440e79498b04328cd6565e646f6e9185c4705ca82aef517bf3342
5
5
  SHA512:
6
- metadata.gz: e24c844b4f17aa7eaffceebdd6b847858855fc0b5526b5537832893fd144e71f427ae352700adff3c5b39ed5cd04c89bc84007d01548ff97ba701a45e8c836b9
7
- data.tar.gz: d1a7d5e0d45c5f9b03388259b45a5a6ce659dd39378a0f13e8f31cf36d7f969a332076cada74b564f2fd7d504c646df0715778679d0e67743a5f0ef08398e7c9
6
+ metadata.gz: b92b7cce247a690370cfcf3f0941a30c63e35678b51228f784046ec9e04922d7632279d6f0d8f20641b168a06c5aa6f5198881b2060b65b4bad1e0abbffbf8c7
7
+ data.tar.gz: d005e21357274d3ef2a53601a0b7c9647d3fedef049a3ef64064b59ed1c3ee6f9c160c8304d05b5886da70c5b5227a2dabebfe3dce4e328ae62ad7bd37c4e2de
data/README.md CHANGED
@@ -71,6 +71,13 @@ bundle exec cosmo -C config/cosmo.yml -c 20 streams # Streams only
71
71
  - [Streams](#streams)
72
72
  - [Configuration](#configuration)
73
73
  - [Advanced Usage](#-advanced-usage)
74
+ - [Cron](#cron)
75
+ - [Priority Queues](#priority-queues)
76
+ - [Concurrency Limiting](#concurrency-limiting)
77
+ - [Custom Serializers](#custom-serializers)
78
+ - [Error Handling](#error-handling)
79
+ - [Testing](#testing)
80
+ - [Integrations](#integrations)
74
81
  - [CLI Reference](#-cli-reference)
75
82
  - [Deployment](#-deployment)
76
83
  - [Monitoring](#-monitoring)
@@ -138,6 +145,8 @@ nothing else to run.
138
145
  - **Automatic retries** — exponential backoff, configurable attempts
139
146
  - **Dead letter queue** — capture permanently failed jobs
140
147
  - **Job uniqueness** — prevent duplicate execution
148
+ - **Concurrency limits** — cap simultaneous executions per class or per key
149
+ - **Cron scheduling** — recurring jobs manageable live from the web UI
141
150
 
142
151
  ### 🌊 Stream Processing
143
152
  - **Real-time event streams** — process continuous data feeds
@@ -145,6 +154,7 @@ nothing else to run.
145
154
  - **Message replay** — reprocess from any point in time
146
155
  - **Consumer groups** — load-balanced across workers
147
156
  - **Custom serialization** — JSON, MessagePack, Protobuf
157
+ - **Pause / resume** — stop and restart a stream's processing without losing its position
148
158
 
149
159
 
150
160
  ## 📦 Installation
@@ -407,7 +417,48 @@ export COSMO_STREAMS_FETCH_TIMEOUT=0.1
407
417
 
408
418
  ## 🔧 Advanced Usage
409
419
 
410
- **Priority Queues:**
420
+ ### Cron
421
+
422
+ Recurring jobs, without a separate scheduler process. A schedule is just a message parked in the
423
+ job's own NATS stream (requires NATS Server 2.14+) — NATS fires it on the cron expression, and it
424
+ lands back in the stream as a regular job. Deploy it once; whatever's in NATS is exactly what runs
425
+ and exactly what shows up in the web UI's **Crons** tab, where each entry can be inspected, run
426
+ immediately, or deleted.
427
+
428
+ Declare schedules right in `config/cosmo.yml`:
429
+
430
+ ```yaml
431
+ setup:
432
+ cron:
433
+ daily_report:
434
+ class: ReportJob
435
+ schedule: "@daily" # @-shortcuts are passed straight through to NATS
436
+ stream: default
437
+ weekday_digest:
438
+ class: ReportJob
439
+ schedule: "0 9 * * 1-5" # 6-field NATS cron (seconds first); 5-field UNIX cron is auto-normalized
440
+ stream: default
441
+ args: ["daily"]
442
+ timezone: America/New_York # optional, cron expressions only
443
+ ```
444
+
445
+ `cosmo -C config/cosmo.yml -S` syncs it — whatever's in the file is exactly what ends up scheduled in NATS, same as streams.
446
+
447
+ Prefer to manage schedules at runtime instead? The same operations are available from Ruby:
448
+
449
+ ```ruby
450
+ Cosmo::API::Cron.instance.upsert!(
451
+ class_name: "ReportJob", stream: "default", schedule: "0 9 * * 1-5",
452
+ args: ["daily"], timezone: "America/New_York", name: "weekday_report"
453
+ )
454
+
455
+ Cosmo::API::Cron.instance.all # every schedule currently deployed
456
+ Cosmo::API::Cron.instance.run_now!("cosmo.cron.default.report_job.weekday_report") # bypass the timer
457
+ Cosmo::API::Cron.instance.delete!("cosmo.cron.default.report_job.weekday_report") # stop future firings
458
+ ```
459
+
460
+ ### Priority Queues
461
+
411
462
  ```ruby
412
463
  class UrgentJob
413
464
  include Cosmo::Job
@@ -415,7 +466,30 @@ class UrgentJob
415
466
  end
416
467
  ```
417
468
 
418
- **Custom Serializers:**
469
+ ### Concurrency Limiting
470
+
471
+ ```ruby
472
+ class ThirdPartyApiJob
473
+ include Cosmo::Job
474
+ # At most 3 instances of this job run at once, cluster-wide.
475
+ # Jobs that lose the race are NAK'd with a delay equal to `duration`
476
+ # so they aren't redelivered until a slot is guaranteed free.
477
+ options limit: { duration: 30, concurrency: 3 }
478
+ end
479
+
480
+ class PerAccountSyncJob
481
+ include Cosmo::Job
482
+ # Scope the cap per key instead of class-wide — e.g. one concurrent sync per account.
483
+ options limit: { duration: 30, concurrency: { to: 1, key: ->(account_id) { account_id } } }
484
+
485
+ def perform(account_id)
486
+ Account.find(account_id).sync!
487
+ end
488
+ end
489
+ ```
490
+
491
+ ### Custom Serializers
492
+
419
493
  ```ruby
420
494
  module MessagePackSerializer
421
495
  def self.serialize(data) = MessagePack.pack(data)
@@ -428,7 +502,8 @@ class FastStream
428
502
  end
429
503
  ```
430
504
 
431
- **Error Handling:**
505
+ ### Error Handling
506
+
432
507
  ```ruby
433
508
  class ResilientJob
434
509
  include Cosmo::Job
@@ -446,7 +521,8 @@ class ResilientJob
446
521
  end
447
522
  ```
448
523
 
449
- **Testing:**
524
+ ### Testing
525
+
450
526
  ```ruby
451
527
  # Synchronous — no NATS needed
452
528
  SendEmailJob.perform_sync(123, "test")
@@ -457,6 +533,40 @@ assert_kind_of String, jid
457
533
  ```
458
534
 
459
535
 
536
+ ### Integrations
537
+
538
+ **ActiveJob:**
539
+ ```ruby
540
+ # config/application.rb
541
+ config.active_job.queue_adapter = :cosmonats
542
+ ```
543
+ The ActiveJob queue name maps directly to a Cosmo stream. Use `cosmo_options` for anything
544
+ Cosmo-specific — retries, DLQ behavior, or overriding the target stream:
545
+ ```ruby
546
+ class ReportJob < ApplicationJob
547
+ cosmo_options retry: 5, dead: false, stream: :critical
548
+
549
+ def perform(report_id)
550
+ Report.find(report_id).generate!
551
+ end
552
+ end
553
+ ```
554
+ Inside a Rails app this is wired up automatically by the bundled Railtie — it registers the
555
+ adapter and loads `config/cosmo.yml` if present. Outside Rails:
556
+ ```ruby
557
+ require "cosmo/active_job"
558
+ ActiveJob::Base.queue_adapter = Cosmo::ActiveJobAdapter::Adapter.new
559
+ ```
560
+
561
+ **Sentry:**
562
+ ```ruby
563
+ require "cosmo/sentry/auto"
564
+ ```
565
+ Wraps every job execution in a Sentry transaction (`queue.cosmonats`) and captures unhandled
566
+ exceptions with the job's id, stream, subject, and retry count attached as context — no other
567
+ setup beyond having `sentry-ruby` initialized.
568
+
569
+
460
570
  ## 🖥️ CLI Reference
461
571
 
462
572
  ```bash
@@ -542,6 +652,12 @@ sudo systemctl enable cosmo && sudo systemctl start cosmo
542
652
 
543
653
  ## 📊 Monitoring
544
654
 
655
+ **Web UI** — mount `Cosmo::Web` (see [Installation](#-installation)) for a live, htmx-powered dashboard:
656
+ - **Jobs** — enqueued, scheduled, busy, and dead views, with per-job retry and delete
657
+ - **Streams** — per-stream state (messages, bytes, consumers) with pause/resume
658
+ - **Crons** — every schedule deployed in NATS, with run-now and delete
659
+ - Summary counters (processed / failed / busy / enqueued / retries / scheduled / dead) backed by a NATS KV counter, no separate metrics store needed
660
+
545
661
  **Structured logs:**
546
662
  ```
547
663
  2026-01-23T10:15:30.123Z INFO pid=12345 tid=abc jid=def: start
data/lib/cosmo/api/kv.rb CHANGED
@@ -8,39 +8,15 @@ module Cosmo
8
8
  def initialize(name, options = nil)
9
9
  @name = name
10
10
  @options = Hash(options)
11
- @kv = Client.instance.kv(@name, **@options)
11
+ @kv = client.kv(@name, **@options)
12
12
  end
13
13
 
14
- def set(key, value, ttl: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
14
+ def set(key, value, ttl: nil)
15
15
  return kv.put(key, value.to_s) unless ttl
16
16
 
17
- # Pass ttl: (seconds) to set a per-message expiry.
17
+ # Pass ttl: (seconds) to set per-message expiry.
18
18
  # Raises `NATS::KeyValue::KeyWrongLastSequenceError` when the key is live.
19
- begin
20
- value = value.to_s
21
- put = lambda do |last_seq:|
22
- headers = { "Nats-Expected-Last-Subject-Sequence" => last_seq.to_s, "Nats-TTL" => "#{ttl.to_i}s" }
23
- Client.instance.js.publish("$KV.#{@name}.#{key}", value, header: headers)
24
- rescue NATS::JetStream::Error::APIError => e
25
- raise NATS::KeyValue::KeyWrongLastSequenceError, e.description if e.err_code == 10_071
26
-
27
- raise
28
- end
29
-
30
- put.call(last_seq: 0)
31
- kv.send(:_get, key) # fetch the created entry to get its revision
32
- rescue NATS::KeyValue::KeyWrongLastSequenceError
33
- # `kv.get` converts KeyDeletedError → KeyNotFoundError, hiding tombstone info.
34
- # Use private _get instead — it raises KeyDeletedError with the entry's revision
35
- begin
36
- kv.send(:_get, key)
37
- rescue NATS::KeyValue::KeyDeletedError => e
38
- put.call(last_seq: e.entry.revision)
39
- return kv.send(:_get, key)
40
- end
41
-
42
- raise
43
- end
19
+ publish_cas(key, value.to_s, ttl, last_seq: 0).seq
44
20
  end
45
21
 
46
22
  def get(key)
@@ -49,6 +25,9 @@ module Cosmo
49
25
  # nop
50
26
  end
51
27
 
28
+ # Writes a KV-Operation tombstone. On a ttl-bearing bucket this leaves the
29
+ # subject occupied, so a subsequent #set(ttl:) CAS with last_seq: 0 will
30
+ # keep failing -- use #erase on those buckets instead.
52
31
  def delete(key)
53
32
  kv.delete(key)
54
33
  end
@@ -66,12 +45,21 @@ module Cosmo
66
45
  results
67
46
  end
68
47
 
48
+ # Writes a KV-Operation tombstone (same issue as #delete on ttl buckets).
69
49
  def purge(key)
70
50
  kv.purge(key)
71
51
  end
72
52
 
53
+ # Removes +key+ leaving no trace at all -- unlike #delete/#purge, which
54
+ # write a KV-Operation tombstone message. Mirrors how per-message
55
+ # Nats-TTL expiry removes a key, so callers never have to distinguish
56
+ # "deleted" from "TTL-expired" on read.
57
+ def erase(key)
58
+ client.purge("KV_#{@name}", "$KV.#{@name}.#{key}")
59
+ end
60
+
73
61
  def clean
74
- Client.instance.purge("KV_#{@name}", ">")
62
+ client.purge("KV_#{@name}", ">")
75
63
  end
76
64
 
77
65
  def count
@@ -80,6 +68,26 @@ module Cosmo
80
68
  0
81
69
  end
82
70
  alias size count
71
+
72
+ private
73
+
74
+ # CAS = Compare-And-Swap: publish +value+ with a per-message Nats-TTL,
75
+ # but only if the subject's current last sequence matches +last_seq+
76
+ # (sent as the Nats-Expected-Last-Subject-Sequence header). Raises
77
+ # NATS::KeyValue::KeyWrongLastSequenceError if it doesn't match --
78
+ # e.g. last_seq: 0 means "only publish if nothing exists here yet".
79
+ def publish_cas(key, value, ttl, last_seq:)
80
+ headers = { "Nats-Expected-Last-Subject-Sequence" => last_seq.to_s, "Nats-TTL" => "#{ttl.to_i}s" }
81
+ client.js.publish("$KV.#{@name}.#{key}", value, header: headers)
82
+ rescue NATS::JetStream::Error::APIError => e
83
+ raise NATS::KeyValue::KeyWrongLastSequenceError, e.description if e.err_code == 10_071
84
+
85
+ raise
86
+ end
87
+
88
+ def client
89
+ Client.instance
90
+ end
83
91
  end
84
92
  end
85
93
  end
@@ -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)
@@ -9,8 +9,9 @@ module Cosmo
9
9
  #
10
10
  # Acquiring a slot is a single atomic `set` (CAS with last-revision=0).
11
11
  # Only one worker can win a given slot; losers try the next number.
12
- # When a job finishes the slot is deleted; if the worker crashes NATS
13
- # expires it automatically via the per-message Nats-TTL header.
12
+ # When a job finishes, the slot is erased; if the worker crashes, NATS
13
+ # expires it automatically via the per-message Nats-TTL header. Both
14
+ # paths leave the slot equally empty -- no tombstone, no delete marker.
14
15
  class Limit
15
16
  BUCKET = "cosmo_jobs_limits"
16
17
 
@@ -40,11 +41,14 @@ module Cosmo
40
41
  nil # all slots occupied
41
42
  end
42
43
 
43
- # Release a previously acquired slot.
44
+ # Release a previously acquired slot. Erases the slot entirely (no
45
+ # tombstone left behind) so a released slot looks identical to one
46
+ # reclaimed by Nats-TTL expiry -- callers never have to special-case a
47
+ # delete marker.
44
48
  def release(slot)
45
- @kv.delete(slot)
49
+ @kv.erase(slot)
46
50
  rescue NATS::Error
47
- # best effort — slot TTL will reclaim it if delete fails
51
+ # best effort — slot TTL will reclaim it if erase fails
48
52
  end
49
53
  end
50
54
  end
@@ -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
@@ -110,7 +107,7 @@ module Cosmo
110
107
 
111
108
  # Tries to acquire a concurrency slot for the job.
112
109
  # Returns the slot key (String) on success, or false if all slots are
113
- # taken (message is NAK'd with a delay equal to +duration+ before returning).
110
+ # taken (a message is NAK'd with a delay of +retry_in+ before returning
114
111
  def acquire_concurrency_slot(worker_class, message, data)
115
112
  options = worker_class.concurrency_options
116
113
  key = worker_class.concurrency_key(data[:args])
@@ -118,7 +115,7 @@ module Cosmo
118
115
  slot = Limit.instance.acquire(key, jid: data[:jid], limit: options[:limit], duration: options[:duration])
119
116
  return slot if slot
120
117
 
121
- message.nak(delay: options[:duration] * Config::NANO)
118
+ message.nak(delay: options[:retry_in] * Config::NANO)
122
119
  Logger.debug "concurrency limit reached for #{data[:class]}, re-queueing back #{data[:jid]}"
123
120
  false
124
121
  rescue NATS::Error => e
@@ -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
data/lib/cosmo/job.rb CHANGED
@@ -19,14 +19,19 @@ module Cosmo
19
19
  # limit: { duration: 30 }
20
20
  # limit: { duration: 30, concurrency: 3 }
21
21
  # limit: { duration: 30, concurrency: { to: 3, key: ->(id) { id } } }
22
+ # limit: { duration: 30, concurrency: 3, retry_in: 5 }
22
23
  #
23
24
  # @option config [Integer] :"limit[:duration]" hard execution timeout in seconds. The job thread is
24
25
  # killed after this many seconds and counts as a failed attempt (retried with exponential backoff,
25
26
  # moved to DLQ after retries exhausted).
26
27
  # @option config [Integer, Hash] :"limit[:concurrency]" caps how many instances run at once across all
27
- # workers. Jobs that cannot acquire a slot are NAK'd with a delay equal to +duration+ so they are not
28
- # re-delivered until the slot is guaranteed free. Requires +duration+.
28
+ # workers. Jobs that cannot acquire a slot are NAK'd (see +retry_in+) so they are not re-delivered until
29
+ # the slot is likely free. Requires +duration+.
29
30
  # 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
32
+ # NAK'd for lack of a concurrency slot (default: half of +duration+). Counts against the same delivery
33
+ # counter as any other retry -- a job stuck behind the concurrency limit for enough consecutive
34
+ # attempts is dropped/DLQ'd exactly like one that keeps failing outright.
30
35
  def options(**config)
31
36
  if config[:limit] && config.dig(:limit, :concurrency) && !config.dig(:limit, :duration).to_i.positive?
32
37
  raise ArgumentError, "limit: duration is required when concurrency is set"
@@ -41,15 +46,17 @@ module Cosmo
41
46
  end
42
47
 
43
48
  # Returns a normalized concurrency config hash, or +nil+ when not configured.
44
- # Always contains +:limit+, +:key+, and +:duration+.
49
+ # Always contains +:limit+, +:key+, +:duration+, and +:retry_in+.
45
50
  def concurrency_options
46
51
  value = default_options.dig(:limit, :concurrency)
47
- duration = default_options.dig(:limit, :duration).to_i
48
52
  return unless value
49
53
 
54
+ duration = default_options.dig(:limit, :duration).to_i
55
+ retry_in = default_options.dig(:limit, :retry_in)&.to_i || (duration / 2)
56
+
50
57
  case value
51
- when Integer then { limit: value, key: nil, duration: duration }
52
- when Hash then { limit: value.fetch(:to), key: value[:key], duration: duration }
58
+ when Integer then { limit: value, key: nil, duration: duration, retry_in: retry_in }
59
+ when Hash then { limit: value.fetch(:to), key: value[:key], duration: duration, retry_in: retry_in }
53
60
  end
54
61
  end
55
62
 
@@ -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.2"
5
5
  end
@@ -489,6 +489,13 @@ time {
489
489
  cursor: default;
490
490
  pointer-events: none;
491
491
  }
492
+ .pagination .btn-primary.btn-disabled {
493
+ opacity: 1;
494
+ }
495
+ .pagination-gap {
496
+ color: var(--color-text-light);
497
+ padding: 0 var(--space-1-2);
498
+ }
492
499
 
493
500
  /* ── Actions ───────────────────────────────────────────────────────────── */
494
501
  .actions-form { display: flex; flex-direction: column; gap: var(--space-1-2); }
@@ -509,6 +516,23 @@ details code { display: block; max-width: 400px; white-space: pre-wrap; }
509
516
  .htmx-request .htmx-indicator,
510
517
  .htmx-request.htmx-indicator { opacity: 1; }
511
518
 
519
+ #global-spinner {
520
+ position: fixed;
521
+ top: var(--space-2x);
522
+ right: var(--space-2x);
523
+ width: 24px;
524
+ height: 24px;
525
+ border: 3px solid var(--color-border);
526
+ border-top-color: var(--color-primary);
527
+ border-radius: 50%;
528
+ animation: spin 0.6s linear infinite;
529
+ z-index: 100;
530
+ }
531
+
532
+ @keyframes spin {
533
+ to { transform: rotate(360deg); }
534
+ }
535
+
512
536
  /* ── Responsive ────────────────────────────────────────────────────────── */
513
537
  @media (max-width: 768px) {
514
538
  .nav { flex-wrap: wrap; }
@@ -67,6 +67,21 @@ module Cosmo
67
67
  Rack::Utils.escape(value.to_s)
68
68
  end
69
69
 
70
+ # Build the list of page numbers to render around the current page, with
71
+ # `:gap` markers where numbers are skipped.
72
+ # pages(5, 20) # => [1, :gap, 3, 4, 5, 6, 7, :gap, 20]
73
+ def pages(page, total_pages, window: 2)
74
+ return [] if total_pages <= 1
75
+
76
+ previous = nil
77
+ candidates = ([1, total_pages] + ((page - window)..(page + window)).to_a).grep(1..total_pages).uniq.sort
78
+ candidates.each_with_object([]) do |p, result|
79
+ pagination_fill_gap(result, previous, p)
80
+ result << p
81
+ previous = p
82
+ end
83
+ end
84
+
70
85
  def current_page?(path)
71
86
  request_path = @request.path_info
72
87
  request_path = "/" if request_path.empty?
@@ -85,6 +100,15 @@ module Cosmo
85
100
  referrer_path = "/" if referrer_path.empty?
86
101
  referrer_path == path
87
102
  end
103
+
104
+ private
105
+
106
+ def pagination_fill_gap(result, previous, page)
107
+ return unless previous
108
+
109
+ result << (previous + 1) if page - previous == 2
110
+ result << :gap if page - previous > 2
111
+ end
88
112
  end
89
113
  end
90
114
  end
@@ -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>
@@ -63,20 +64,37 @@
63
64
  <% if @total_pages > 1 -%>
64
65
  <div class="pagination">
65
66
  <% if @page > 1 -%>
66
- <a hx-get="<%= url_for('/jobs/_enqueued', stream_name: @stream_name, page: @page - 1, limit: @limit) %>"
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-push-url="true"
71
+ hx-indicator="#global-spinner"
69
72
  class="btn">&#x2190; Prev</a>
70
73
  <% else -%>
71
74
  <span class="btn btn-disabled">&#x2190; Prev</span>
72
75
  <% end -%>
73
76
 
74
- <span class="text-muted">Page <%= @page %> of <%= @total_pages %></span>
77
+ <% pages(@page, @total_pages).each do |p| -%>
78
+ <% if p == :gap -%>
79
+ <span class="pagination-gap">&hellip;</span>
80
+ <% elsif p == @page -%>
81
+ <span class="btn btn-primary btn-disabled"><%= p %></span>
82
+ <% else -%>
83
+ <a hx-get="<%= url_for('/jobs/enqueued', stream_name: @stream_name, page: p, limit: @limit) %>"
84
+ hx-target="#enqueued-poller"
85
+ hx-swap="outerHTML"
86
+ hx-push-url="true"
87
+ hx-indicator="#global-spinner"
88
+ class="btn"><%= p %></a>
89
+ <% end -%>
90
+ <% end -%>
75
91
 
76
92
  <% if @page < @total_pages -%>
77
- <a hx-get="<%= url_for('/jobs/_enqueued', stream_name: @stream_name, page: @page + 1, limit: @limit) %>"
93
+ <a hx-get="<%= url_for('/jobs/enqueued', stream_name: @stream_name, page: @page + 1, limit: @limit) %>"
78
94
  hx-target="#enqueued-poller"
79
95
  hx-swap="outerHTML"
96
+ hx-push-url="true"
97
+ hx-indicator="#global-spinner"
80
98
  class="btn">Next &#x2192;</a>
81
99
  <% else -%>
82
100
  <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>
data/sig/cosmo/api/kv.rbs CHANGED
@@ -19,10 +19,18 @@ module Cosmo
19
19
 
20
20
  def purge: (::String | ::Integer key) -> untyped
21
21
 
22
+ def erase: (::String | ::Integer key) -> ::Integer?
23
+
22
24
  def clean: () -> untyped
23
25
 
24
26
  def count: () -> ::Integer
25
27
  alias size count
28
+
29
+ private
30
+
31
+ def publish_cas: (::String | ::Integer key, ::String value, ::Integer? ttl, last_seq: ::Integer) -> untyped
32
+
33
+ def client: () -> Client
26
34
  end
27
35
  end
28
36
  end
@@ -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.2
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: []