cosmonats 0.3.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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +208 -156
  3. data/lib/cosmo/active_job/adapter.rb +46 -0
  4. data/lib/cosmo/active_job/executor.rb +16 -0
  5. data/lib/cosmo/active_job/options.rb +50 -0
  6. data/lib/cosmo/active_job.rb +29 -0
  7. data/lib/cosmo/api/busy.rb +2 -2
  8. data/lib/cosmo/api/counter.rb +2 -2
  9. data/lib/cosmo/api/cron/entry.rb +99 -0
  10. data/lib/cosmo/api/cron.rb +118 -0
  11. data/lib/cosmo/api/kv.rb +35 -13
  12. data/lib/cosmo/api/stream.rb +43 -8
  13. data/lib/cosmo/api.rb +1 -0
  14. data/lib/cosmo/cli.rb +27 -10
  15. data/lib/cosmo/client.rb +52 -4
  16. data/lib/cosmo/config.rb +9 -0
  17. data/lib/cosmo/job/data.rb +1 -1
  18. data/lib/cosmo/job/limit.rb +51 -0
  19. data/lib/cosmo/job/processor.rb +63 -7
  20. data/lib/cosmo/job.rb +51 -2
  21. data/lib/cosmo/processor.rb +1 -1
  22. data/lib/cosmo/railtie.rb +21 -0
  23. data/lib/cosmo/sentry/auto.rb +6 -0
  24. data/lib/cosmo/sentry/job_processor_middleware.rb +66 -0
  25. data/lib/cosmo/stream/processor.rb +2 -2
  26. data/lib/cosmo/stream.rb +2 -1
  27. data/lib/cosmo/utils/hash.rb +13 -0
  28. data/lib/cosmo/utils/overrides.rb +1 -1
  29. data/lib/cosmo/version.rb +1 -1
  30. data/lib/cosmo/web/assets/app.css +59 -0
  31. data/lib/cosmo/web/controllers/crons.rb +41 -0
  32. data/lib/cosmo/web/controllers/jobs.rb +7 -3
  33. data/lib/cosmo/web/controllers/streams.rb +1 -1
  34. data/lib/cosmo/web/helpers/application.rb +4 -0
  35. data/lib/cosmo/web/views/actions/index.erb +1 -1
  36. data/lib/cosmo/web/views/crons/_table.erb +58 -0
  37. data/lib/cosmo/web/views/crons/index.erb +10 -0
  38. data/lib/cosmo/web/views/jobs/_busy.erb +54 -49
  39. data/lib/cosmo/web/views/jobs/_dead.erb +70 -65
  40. data/lib/cosmo/web/views/jobs/_enqueued.erb +85 -56
  41. data/lib/cosmo/web/views/jobs/_scheduled.erb +53 -48
  42. data/lib/cosmo/web/views/jobs/_stats.erb +5 -1
  43. data/lib/cosmo/web/views/jobs/_tabs.erb +6 -0
  44. data/lib/cosmo/web/views/jobs/busy.erb +8 -6
  45. data/lib/cosmo/web/views/jobs/dead.erb +6 -5
  46. data/lib/cosmo/web/views/jobs/enqueued.erb +8 -6
  47. data/lib/cosmo/web/views/jobs/index.erb +1 -1
  48. data/lib/cosmo/web/views/jobs/scheduled.erb +6 -5
  49. data/lib/cosmo/web/views/layout.erb +2 -1
  50. data/lib/cosmo/web.rb +5 -0
  51. data/lib/cosmo.rb +1 -0
  52. data/sig/cosmo/active_job/adapter.rbs +13 -0
  53. data/sig/cosmo/active_job/executor.rbs +9 -0
  54. data/sig/cosmo/active_job/options.rbs +14 -0
  55. data/sig/cosmo/api/cron/entry.rbs +30 -0
  56. data/sig/cosmo/api/cron.rbs +25 -0
  57. data/sig/cosmo/api/kv.rbs +4 -6
  58. data/sig/cosmo/api/stream.rbs +6 -0
  59. data/sig/cosmo/client.rbs +9 -1
  60. data/sig/cosmo/job/data.rbs +1 -1
  61. data/sig/cosmo/job/limit.rbs +18 -0
  62. data/sig/cosmo/job/processor.rbs +3 -1
  63. data/sig/cosmo/job.rbs +9 -4
  64. data/sig/cosmo/railtie.rbs +4 -0
  65. data/sig/cosmo/utils/hash.rbs +4 -0
  66. metadata +23 -2
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module ActiveJobAdapter
5
+ # Adds +cosmo_options+ to ActiveJob classes.
6
+ #
7
+ # class MyJob < ApplicationJob
8
+ # cosmo_options retry: 5, dead: false
9
+ #
10
+ # def perform(user_id)
11
+ # # ...
12
+ # end
13
+ # end
14
+ #
15
+ # Options mirror those accepted by +Cosmo::Job+:
16
+ # retry: [Integer] Number of retries before giving up (default: 3)
17
+ # dead: [Boolean] Move to DLQ when retries exhausted? (default: true)
18
+ # stream: [Symbol] Override the NATS stream (default: derived from queue_name)
19
+ module Options
20
+ VALID_OPTIONS = %i[retry dead stream].freeze
21
+
22
+ def self.included(base)
23
+ base.extend(ClassMethods)
24
+ end
25
+
26
+ module ClassMethods
27
+ # Set Cosmo-specific options for this job class.
28
+ # Merges with any options inherited from a superclass.
29
+ # Raises +ArgumentError+ for unknown keys.
30
+ def cosmo_options(**opts)
31
+ unknown = opts.keys - Cosmo::ActiveJobAdapter::Options::VALID_OPTIONS
32
+ raise ::ArgumentError, "Unknown cosmo_options key(s): #{unknown.join(", ")}" if unknown.any?
33
+
34
+ @cosmo_options = get_cosmo_options.merge(opts)
35
+ end
36
+
37
+ # Returns the resolved options, walking up the inheritance chain.
38
+ def get_cosmo_options # rubocop:disable Naming/AccessorMethodName
39
+ if @cosmo_options
40
+ @cosmo_options.dup
41
+ elsif superclass.respond_to?(:get_cosmo_options)
42
+ superclass.get_cosmo_options
43
+ else
44
+ {}
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/active_job/options"
4
+ require "cosmo/active_job/executor"
5
+ require "cosmo/active_job/adapter"
6
+
7
+ module Cosmo
8
+ # ActiveJob integration for Cosmonats.
9
+ #
10
+ # In a Rails app the Railtie (loaded via cosmonats) handles everything.
11
+ # For standalone use:
12
+ #
13
+ # require "cosmo/active_job"
14
+ # ActiveJob::Base.queue_adapter = Cosmo::ActiveJobAdapter::Adapter.new
15
+ module ActiveJobAdapter
16
+ end
17
+ end
18
+
19
+ # Register the adapter under the conventional ActiveJob name so that
20
+ # `config.active_job.queue_adapter = :cosmonats` resolves automatically.
21
+ if defined?(ActiveJob)
22
+ ActiveJob::QueueAdapters::CosmonatsAdapter = Cosmo::ActiveJobAdapter::Adapter
23
+
24
+ if defined?(ActiveSupport)
25
+ ActiveSupport.on_load(:active_job) { include Cosmo::ActiveJobAdapter::Options }
26
+ else
27
+ ActiveJob::Base.include(Cosmo::ActiveJobAdapter::Options)
28
+ end
29
+ end
@@ -7,7 +7,7 @@ module Cosmo
7
7
  class Busy
8
8
  TTL = 70
9
9
  HEARTBEAT = 30
10
- BUCKET = "cosmostats"
10
+ BUCKET = "cosmo_jobs_busy"
11
11
 
12
12
  def self.instance
13
13
  @instance ||= new
@@ -40,7 +40,7 @@ module Cosmo
40
40
  end
41
41
 
42
42
  def list(limit: 25)
43
- @kv.keys(limit:).filter_map { Utils::Json.parse(@kv.get(_1)) }.map { _1.merge(data: Utils::Json.parse(_1[:data])) }
43
+ @kv.keys(limit:).filter_map { Utils::Json.parse(@kv.get(_1)&.value) }.map { _1.merge(data: Utils::Json.parse(_1[:data])) }
44
44
  end
45
45
 
46
46
  def size
@@ -3,7 +3,7 @@
3
3
  module Cosmo
4
4
  module API
5
5
  class Counter
6
- STREAM_NAME = "cosmostats"
6
+ STREAM_NAME = "_cosmostats"
7
7
 
8
8
  def self.instance
9
9
  @instance ||= new("jobs")
@@ -38,7 +38,7 @@ module Cosmo
38
38
  def get(key)
39
39
  raw = client.get_message(STREAM_NAME, direct: true, subject: subject(key))
40
40
  Utils::Json.parse(raw.data, default: { "val" => 0 })[:val].to_i
41
- rescue NATS::JetStream::Error::NotFound, NATS::JetStream::Error::ServiceUnavailable
41
+ rescue NATS::JetStream::Error::NotFound, NATS::JetStream::Error::ServiceUnavailable, NATS::IO::Timeout
42
42
  0
43
43
  end
44
44
 
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "json"
5
+
6
+ module Cosmo
7
+ module API
8
+ class Cron
9
+ # Value object representing a single cron schedule entry.
10
+ # Each schedule maps one job class (and optional args) to a NATS 2.14 message schedule.
11
+ #
12
+ # NATS 2.14 message scheduling uses a 6-field cron format:
13
+ # second minute hour day-of-month month day-of-week
14
+ #
15
+ # @-shortcuts (@daily, @every 5m, @at ...) are passed through unchanged —
16
+ # the server understands them natively. Plain 5-field UNIX cron expressions
17
+ # need a seconds field prepended to become valid 6-field expressions.
18
+ #
19
+ # "0 9 * * 1-5" → "0 0 9 * * 1-5" (at 09:00 on weekdays)
20
+ # "@daily" → "@daily" (unchanged)
21
+ class Entry
22
+ SUBJECT_PREFIX = "cosmo.cron"
23
+
24
+ def self.normalize_expression(expr)
25
+ str = expr.to_s.strip
26
+ return str if str.start_with?("@")
27
+
28
+ fields = str.split
29
+ fields.size == 5 ? "0 #{str}" : str
30
+ end
31
+
32
+ attr_reader :class_name, :stream, :expression, :args, :timezone, :name
33
+
34
+ # @param class_name [String] Fully-qualified Ruby class name (e.g. "ReportJob")
35
+ # @param stream [String] Target job stream name (e.g. "default")
36
+ # @param expression [String] NATS schedule expression (@daily, @every 5m, "0 0 9 * * 1-5", etc.)
37
+ # @param args [Array] Arguments passed to the job's +perform+ method
38
+ # @param timezone [String, nil] IANA timezone name (e.g. "America/New_York"). Cron expressions only.
39
+ # @param name [String, Symbol, nil] Disambiguates multiple schedules on the same class.
40
+ def initialize(class_name:, stream:, expression:, args: [], timezone: nil, name: nil)
41
+ @class_name = class_name.to_s
42
+ @stream = stream.to_s
43
+ @expression = self.class.normalize_expression(expression)
44
+ @args = Array(args)
45
+ @timezone = timezone
46
+ @name = name&.to_s
47
+ end
48
+
49
+ # Subject where the schedule message lives in NATS (one per unique schedule).
50
+ # e.g. "cosmo.cron.default.report_job" or "cosmo.cron.default.report_job.monthly"
51
+ def schedule_subject
52
+ parts = [SUBJECT_PREFIX, @stream, job_name]
53
+ parts << @name if @name
54
+ parts.join(".")
55
+ end
56
+
57
+ # Subject where NATS fires the generated job message.
58
+ # Must be a subject covered by the same stream.
59
+ def target_subject
60
+ "jobs.#{@stream}.#{job_name}"
61
+ end
62
+
63
+ def job_name
64
+ @job_name ||= Utils::String.underscore(@class_name)
65
+ end
66
+
67
+ def as_json
68
+ {
69
+ class: @class_name,
70
+ stream: @stream,
71
+ schedule: @expression,
72
+ timezone: @timezone,
73
+ args: @args,
74
+ name: @name,
75
+ schedule_subject: schedule_subject,
76
+ target_subject: target_subject
77
+ }.compact
78
+ end
79
+
80
+ # JSON payload sent as the body of the schedule message.
81
+ # Mirrors the format produced by Job::Data so the job processor can handle it.
82
+ def job_payload
83
+ Utils::Json.dump({
84
+ jid: SecureRandom.hex(12),
85
+ class: @class_name,
86
+ args: @args,
87
+ retry: ::Cosmo::Job::Data::DEFAULTS[:retry],
88
+ dead: ::Cosmo::Job::Data::DEFAULTS[:dead]
89
+ })
90
+ end
91
+
92
+ def to_s
93
+ "#<Cosmo::API::Cron::Entry class=#{@class_name} expression=#{@expression} stream=#{@stream}>"
94
+ end
95
+ alias inspect to_s
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "cosmo/api/cron/entry"
5
+
6
+ module Cosmo
7
+ module API
8
+ # Web-facing API for cron schedules. Single interface for all cron NATS operations.
9
+ #
10
+ # Derives the schedule list entirely from NATS.
11
+ # Whatever is deployed in NATS is exactly what appears in the UI.
12
+ #
13
+ # Schedule templates live in the same job stream they target (e.g. +default+),
14
+ # stored at subjects matching +cosmo.cron.<stream>.>+. NATS 2.14 fires each
15
+ # template by publishing the body to +Nats-Schedule-Target+ as a regular
16
+ # JetStream message that accumulates alongside pending jobs.
17
+ class Cron
18
+ def self.instance
19
+ @instance ||= new
20
+ end
21
+
22
+ # @return [Array<Hash>] every cron schedule currently deployed in NATS
23
+ def all
24
+ Stream.jobs.flat_map { |s| schedules_from_stream(s.name) }
25
+ rescue StandardError
26
+ []
27
+ end
28
+
29
+ # Publish (or replace) a schedule message in NATS.
30
+ # @return [Hash, nil] the persisted schedule as a hash, or nil on failure
31
+ def upsert!(class_name: nil, stream: nil, schedule: nil, args: [], timezone: nil, name: nil)
32
+ e = Entry.new(class_name: class_name, stream: stream, expression: schedule,
33
+ args: args, timezone: timezone, name: name)
34
+ headers = {
35
+ "Nats-Schedule" => e.expression,
36
+ "Nats-Schedule-Target" => e.target_subject
37
+ }
38
+ headers["Nats-Schedule-Time-Zone"] = e.timezone if e.timezone
39
+ client.publish(e.schedule_subject, e.job_payload, stream: e.stream, header: headers)
40
+ build_from_nats(e.stream, e.schedule_subject)
41
+ end
42
+
43
+ # Purge the schedule message from NATS (stops future firings).
44
+ # @param subject [String]
45
+ def delete!(subject)
46
+ stream_name = subject.to_s.split(".")[2]
47
+ client.purge(stream_name, subject)
48
+ rescue NATS::JetStream::Error::NotFound, NATS::IO::Timeout
49
+ nil
50
+ end
51
+
52
+ # Dispatch the job immediately to the target stream, bypassing the timer.
53
+ # @param schedule_subject [String] e.g. "cosmo.cron.default.report_job.daily"
54
+ def run_now!(schedule_subject) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
55
+ stream_name = schedule_subject.to_s.split(".")[2]
56
+ msg = client.get_message(stream_name, subject: schedule_subject)
57
+ return unless msg
58
+
59
+ headers = msg.headers || {}
60
+ body = Utils::Json.parse(msg.data) || {}
61
+ target = headers["Nats-Schedule-Target"]
62
+ return unless target && body[:class]
63
+
64
+ payload = Utils::Json.dump({
65
+ jid: SecureRandom.hex(12),
66
+ class: body[:class],
67
+ args: body[:args] || [],
68
+ retry: body[:retry] || Job::Data::DEFAULTS[:retry],
69
+ dead: body[:dead].nil? ? Job::Data::DEFAULTS[:dead] : body[:dead]
70
+ })
71
+ client.publish(target, payload, stream: stream_name)
72
+ rescue NATS::JetStream::Error::NotFound
73
+ nil
74
+ end
75
+
76
+ private
77
+
78
+ def client
79
+ @client ||= Client.instance
80
+ end
81
+
82
+ def schedules_from_stream(stream_name)
83
+ filter = "#{Entry::SUBJECT_PREFIX}.#{stream_name}.>"
84
+ subjects = client.cron_subjects_in_stream(stream_name, filter)
85
+ subjects.filter_map { |subj| build_from_nats(stream_name, subj) }
86
+ end
87
+
88
+ def build_from_nats(stream_name, subject)
89
+ msg = client.get_message(stream_name, subject: subject)
90
+ return unless msg
91
+
92
+ headers = msg.headers || {}
93
+ body = Utils::Json.parse(msg.data) || {}
94
+
95
+ {
96
+ class: body[:class],
97
+ stream: stream_name,
98
+ schedule: headers["Nats-Schedule"],
99
+ timezone: headers["Nats-Schedule-Time-Zone"],
100
+ args: body[:args] || [],
101
+ name: name_from_subject(subject),
102
+ schedule_subject: subject,
103
+ target_subject: headers["Nats-Schedule-Target"],
104
+ registry_key: subject.split(".").drop(2).join("/")
105
+ }
106
+ rescue StandardError
107
+ nil
108
+ end
109
+
110
+ # "cosmo.cron.default.report_job" → nil
111
+ # "cosmo.cron.default.report_job.monthly" → "monthly"
112
+ def name_from_subject(subject)
113
+ parts = subject.to_s.split(".")
114
+ parts.length > 4 ? parts.drop(4).join(".") : nil
115
+ end
116
+ end
117
+ end
118
+ end
data/lib/cosmo/api/kv.rb CHANGED
@@ -3,17 +3,48 @@
3
3
  module Cosmo
4
4
  module API
5
5
  class KV
6
+ attr_reader :kv
7
+
6
8
  def initialize(name, options = nil)
7
9
  @name = name
8
10
  @options = Hash(options)
11
+ @kv = Client.instance.kv(@name, **@options)
9
12
  end
10
13
 
11
- def set(key, value)
12
- kv.put(key, value.to_s)
14
+ def set(key, value, ttl: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
15
+ return kv.put(key, value.to_s) unless ttl
16
+
17
+ # Pass ttl: (seconds) to set a per-message expiry.
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
13
44
  end
14
45
 
15
46
  def get(key)
16
- kv.get(key).value
47
+ kv.get(key)
17
48
  rescue NATS::KeyValue::KeyNotFoundError
18
49
  # nop
19
50
  end
@@ -24,9 +55,7 @@ module Cosmo
24
55
 
25
56
  def keys(subject = nil, limit: 25)
26
57
  results = []
27
- params = { ignore_deletes: true, meta_only: true }
28
- watcher = kv.watch(subject || ">", params)
29
-
58
+ watcher = kv.watch(subject || ">", ignore_deletes: true, meta_only: true)
30
59
  watcher.each do |entry|
31
60
  break unless entry
32
61
 
@@ -34,7 +63,6 @@ module Cosmo
34
63
  break if results.size >= limit
35
64
  end
36
65
  watcher.stop
37
-
38
66
  results
39
67
  end
40
68
 
@@ -52,12 +80,6 @@ module Cosmo
52
80
  0
53
81
  end
54
82
  alias size count
55
-
56
- private
57
-
58
- def kv
59
- @kv ||= Client.instance.kv(@name, **@options)
60
- end
61
83
  end
62
84
  end
63
85
  end
@@ -35,31 +35,36 @@ module Cosmo
35
35
  end
36
36
 
37
37
  def total
38
- info[:state].messages.to_i
39
- rescue StandardError
38
+ all_msgs = info[:state].messages.to_i
39
+ cron_count = Client.instance.cron_subjects_in_stream(name, "#{Cron::Entry::SUBJECT_PREFIX}.#{name}.>").size
40
+ [all_msgs - cron_count, 0].max
41
+ rescue NATS::Error
40
42
  0
41
43
  end
42
44
  alias size total
43
45
 
44
46
  def retries
45
47
  client.list_consumers(name).sum { _1["num_redelivered"].to_i }
48
+ rescue NATS::Error
49
+ 0
46
50
  end
47
51
 
48
52
  def each
49
53
  return if total.zero?
50
54
 
51
- state = info[:state]
52
- current = @offset || state.first_seq.to_i
53
- last = state.last_seq.to_i
55
+ candidates = {}
56
+ current, last, subjects = scan_range
54
57
 
55
58
  loop do
56
59
  break if current > last
57
60
 
58
- job = message(current)
61
+ subject, job = next_candidate(subjects, candidates, current)
59
62
  break unless job
60
63
 
64
+ candidates.delete(subject)
65
+ current = job.seq.to_i + 1
66
+
61
67
  yield job
62
- current += 1
63
68
  end
64
69
  end
65
70
 
@@ -84,7 +89,10 @@ module Cosmo
84
89
  end
85
90
 
86
91
  def message(seq)
87
- Job.new(name, client.get_message(name, seq: seq, direct: true))
92
+ job = Job.new(name, client.get_message(name, seq: seq, direct: true))
93
+ return if job.subject.to_s.start_with?(Cron::Entry::SUBJECT_PREFIX)
94
+
95
+ job
88
96
  rescue NATS::JetStream::Error::NotFound
89
97
  # nop, acked/nacked
90
98
  end
@@ -115,6 +123,33 @@ module Cosmo
115
123
 
116
124
  private
117
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
+
118
153
  def client
119
154
  self.class.client
120
155
  end
data/lib/cosmo/api.rb CHANGED
@@ -4,6 +4,7 @@ require "cosmo/api/stream"
4
4
  require "cosmo/api/counter"
5
5
  require "cosmo/api/kv"
6
6
  require "cosmo/api/stats"
7
+ require "cosmo/api/cron"
7
8
 
8
9
  module Cosmo
9
10
  module API
data/lib/cosmo/cli.rb CHANGED
@@ -4,7 +4,8 @@ require "yaml"
4
4
  require "optparse"
5
5
 
6
6
  module Cosmo
7
- class CLI # rubocop:disable Metrics/ClassLength
7
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
8
+ class CLI
8
9
  def self.run
9
10
  instance.run
10
11
  end
@@ -58,6 +59,10 @@ module Cosmo
58
59
 
59
60
  environment_path = File.expand_path("config/environment.rb")
60
61
  require environment_path if File.exist?(environment_path)
62
+
63
+ # Ensure the ActiveJob integration is loaded when ActiveJob is present
64
+ # but was not already pulled in by the app (e.g. non-Rails setup or the Railtie hasn't fired yet at this point).
65
+ require "cosmo/active_job" if defined?(::ActiveJob) && !defined?(Cosmo::ActiveJobAdapter::Executor)
61
66
  end
62
67
 
63
68
  def require_path(path)
@@ -71,8 +76,8 @@ module Cosmo
71
76
  require_files("app/streams") if File.directory?("app/streams")
72
77
  end
73
78
 
74
- def flags_parser(flags) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
75
- OptionParser.new do |o| # rubocop:disable Metrics/BlockLength
79
+ def flags_parser(flags)
80
+ OptionParser.new do |o|
76
81
  o.banner = "Usage: cosmo [flags] [command] [options]"
77
82
  o.separator ""
78
83
  o.separator "Command:"
@@ -98,20 +103,31 @@ module Cosmo
98
103
  flags[:config_file] = arg
99
104
  end
100
105
 
101
- o.on "-S", "--setup", "Load config, create streams and exit" do
106
+ o.on "-S", "--setup", "Create/update streams and sync cron schedules, then exit" do
102
107
  load_config(flags)
103
108
  boot_application
104
109
 
105
- Config[:setup]&.each_value do |configs|
110
+ Config[:setup]&.each do |type, configs|
111
+ next if type == :cron
112
+
113
+ first_line = true
106
114
  configs.each do |name, config|
107
- Client.instance.stream_info(name)
108
- rescue NATS::JetStream::Error::NotFound
109
- meta = { metadata: { "_cosmo.type" => "jobs" } }
110
- Client.instance.create_stream(name, config.merge(meta))
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
111
119
  end
112
120
  end
113
121
 
114
- puts "Cosmo streams were created successfully"
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."
115
131
  exit(0)
116
132
  end
117
133
 
@@ -234,4 +250,5 @@ module Cosmo
234
250
  end
235
251
  # rubocop:enable Layout/TrailingWhitespace,Lint/IneffectiveAccessModifier
236
252
  end
253
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
237
254
  end
data/lib/cosmo/client.rb CHANGED
@@ -42,12 +42,35 @@ module Cosmo
42
42
  js.update_stream(name: name, **config)
43
43
  end
44
44
 
45
+ # Create/update a stream, falling back to create when there's no stream.
46
+ # @param name [String] Stream name
47
+ # @param config [Hash] Full desired stream configuration
48
+ def setup_stream(name, config)
49
+ update_stream(name, config)
50
+ rescue NATS::JetStream::Error::StreamNotFound
51
+ create_stream(name, config)
52
+ end
53
+
54
+ # Return all subjects in +stream_name+ that match +filter+ using NATS's
55
+ # subjects_filter on STREAM.INFO (requires NATS ≥ 2.9).
56
+ # @return [Array<String>]
57
+ def cron_subjects_in_stream(stream_name, filter)
58
+ payload = Utils::Json.dump({ subjects_filter: filter })
59
+ resp = nc.request("$JS.API.STREAM.INFO.#{stream_name}", payload)
60
+ data = Utils::Json.parse(resp.data, symbolize_names: false)
61
+ (data&.dig("state", "subjects") || {}).keys
62
+ rescue StandardError
63
+ []
64
+ end
65
+
45
66
  def list_streams
46
67
  response = nc.request("$JS.API.STREAM.LIST", "")
47
68
  data = Utils::Json.parse(response.data, symbolize_names: false)
48
69
  return [] if data.nil? || data["streams"].nil?
49
70
 
50
71
  data["streams"]
72
+ rescue NATS::Error
73
+ []
51
74
  end
52
75
 
53
76
  def pause_stream(name)
@@ -80,8 +103,8 @@ module Cosmo
80
103
  js.consumer_info(stream_name, consumer_name)
81
104
  end
82
105
 
83
- def get_message(name, **options)
84
- js.get_msg(name, **options)
106
+ def get_message(stream_name, **options)
107
+ js.get_msg(stream_name, **options)
85
108
  end
86
109
 
87
110
  def delete_message(name, seq)
@@ -98,14 +121,39 @@ module Cosmo
98
121
  result["purged"] # number of messages purged
99
122
  end
100
123
 
101
- def kv(name, **options)
124
+ def kv(name, allow_msg_ttl: false, **options)
102
125
  js.key_value(name)
103
126
  rescue NATS::KeyValue::BucketNotFoundError
104
- js.create_key_value({ bucket: name }.merge(options))
127
+ allow_msg_ttl ? create_kv_with_msg_ttl(name, **options) : js.create_key_value({ bucket: name }.merge(options))
105
128
  end
106
129
 
107
130
  def close
108
131
  nc.close
109
132
  end
133
+
134
+ private
135
+
136
+ # NOTE: KV manager in nats-pure hardcodes the fields it copies into StreamConfig,
137
+ # so `allow_msg_ttl` is never forwarded via create_key_value. Send the raw stream-create API request instead.
138
+ def create_kv_with_msg_ttl(name, **options)
139
+ payload = Utils::Json.dump({
140
+ name: "KV_#{name}",
141
+ subjects: ["$KV.#{name}.>"],
142
+ storage: "file",
143
+ allow_direct: true,
144
+ allow_msg_ttl: true,
145
+ allow_rollup_hdrs: true,
146
+ max_msgs_per_subject: 1
147
+ }.merge(options))
148
+ resp = nc.request("$JS.API.STREAM.CREATE.KV_#{name}", payload)
149
+ result = Utils::Json.parse(resp.data, symbolize_names: false)
150
+ if result&.dig("error")
151
+ msg = result.dig("error", "description").to_s
152
+ # Two worker processes starting simultaneously can both attempt creation.
153
+ # If another process won the race, fall back to looking up the existing bucket.
154
+ raise NATS::JetStream::Error, msg unless msg.match?(/already in use|already exists/i)
155
+ end
156
+ js.key_value(name)
157
+ end
110
158
  end
111
159
  end