cosmonats 0.3.0 → 0.4.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 (62) 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 +10 -5
  13. data/lib/cosmo/api.rb +1 -0
  14. data/lib/cosmo/cli.rb +27 -10
  15. data/lib/cosmo/client.rb +48 -2
  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 +49 -5
  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/stream/processor.rb +2 -2
  24. data/lib/cosmo/stream.rb +2 -1
  25. data/lib/cosmo/utils/hash.rb +13 -0
  26. data/lib/cosmo/utils/overrides.rb +1 -1
  27. data/lib/cosmo/version.rb +1 -1
  28. data/lib/cosmo/web/assets/app.css +42 -0
  29. data/lib/cosmo/web/controllers/crons.rb +41 -0
  30. data/lib/cosmo/web/controllers/jobs.rb +7 -3
  31. data/lib/cosmo/web/controllers/streams.rb +1 -1
  32. data/lib/cosmo/web/helpers/application.rb +4 -0
  33. data/lib/cosmo/web/views/actions/index.erb +1 -1
  34. data/lib/cosmo/web/views/crons/_table.erb +58 -0
  35. data/lib/cosmo/web/views/crons/index.erb +10 -0
  36. data/lib/cosmo/web/views/jobs/_busy.erb +54 -49
  37. data/lib/cosmo/web/views/jobs/_dead.erb +70 -65
  38. data/lib/cosmo/web/views/jobs/_enqueued.erb +82 -56
  39. data/lib/cosmo/web/views/jobs/_scheduled.erb +53 -48
  40. data/lib/cosmo/web/views/jobs/_tabs.erb +6 -0
  41. data/lib/cosmo/web/views/jobs/busy.erb +8 -6
  42. data/lib/cosmo/web/views/jobs/dead.erb +6 -5
  43. data/lib/cosmo/web/views/jobs/enqueued.erb +8 -6
  44. data/lib/cosmo/web/views/jobs/index.erb +1 -1
  45. data/lib/cosmo/web/views/jobs/scheduled.erb +6 -5
  46. data/lib/cosmo/web/views/layout.erb +1 -1
  47. data/lib/cosmo/web.rb +5 -0
  48. data/lib/cosmo.rb +1 -0
  49. data/sig/cosmo/active_job/adapter.rbs +13 -0
  50. data/sig/cosmo/active_job/executor.rbs +9 -0
  51. data/sig/cosmo/active_job/options.rbs +14 -0
  52. data/sig/cosmo/api/cron/entry.rbs +30 -0
  53. data/sig/cosmo/api/cron.rbs +25 -0
  54. data/sig/cosmo/api/kv.rbs +4 -6
  55. data/sig/cosmo/client.rbs +9 -1
  56. data/sig/cosmo/job/data.rbs +1 -1
  57. data/sig/cosmo/job/limit.rbs +18 -0
  58. data/sig/cosmo/job/processor.rbs +3 -1
  59. data/sig/cosmo/job.rbs +9 -4
  60. data/sig/cosmo/railtie.rbs +4 -0
  61. data/sig/cosmo/utils/hash.rbs +4 -0
  62. metadata +20 -1
@@ -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,8 +35,10 @@ 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
@@ -56,10 +58,10 @@ module Cosmo
56
58
  break if current > last
57
59
 
58
60
  job = message(current)
59
- break unless job
61
+ current += 1
62
+ next unless job
60
63
 
61
64
  yield job
62
- current += 1
63
65
  end
64
66
  end
65
67
 
@@ -84,7 +86,10 @@ module Cosmo
84
86
  end
85
87
 
86
88
  def message(seq)
87
- Job.new(name, client.get_message(name, seq: seq, direct: true))
89
+ job = Job.new(name, client.get_message(name, seq: seq, direct: true))
90
+ return if job.subject.to_s.start_with?(Cron::Entry::SUBJECT_PREFIX)
91
+
92
+ job
88
93
  rescue NATS::JetStream::Error::NotFound
89
94
  # nop, acked/nacked
90
95
  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
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,6 +42,27 @@ 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)
@@ -98,14 +119,39 @@ module Cosmo
98
119
  result["purged"] # number of messages purged
99
120
  end
100
121
 
101
- def kv(name, **options)
122
+ def kv(name, allow_msg_ttl: false, **options)
102
123
  js.key_value(name)
103
124
  rescue NATS::KeyValue::BucketNotFoundError
104
- js.create_key_value({ bucket: name }.merge(options))
125
+ allow_msg_ttl ? create_kv_with_msg_ttl(name, **options) : js.create_key_value({ bucket: name }.merge(options))
105
126
  end
106
127
 
107
128
  def close
108
129
  nc.close
109
130
  end
131
+
132
+ private
133
+
134
+ # NOTE: KV manager in nats-pure hardcodes the fields it copies into StreamConfig,
135
+ # so `allow_msg_ttl` is never forwarded via create_key_value. Send the raw stream-create API request instead.
136
+ def create_kv_with_msg_ttl(name, **options)
137
+ payload = Utils::Json.dump({
138
+ name: "KV_#{name}",
139
+ subjects: ["$KV.#{name}.>"],
140
+ storage: "file",
141
+ allow_direct: true,
142
+ allow_msg_ttl: true,
143
+ allow_rollup_hdrs: true,
144
+ max_msgs_per_subject: 1
145
+ }.merge(options))
146
+ resp = nc.request("$JS.API.STREAM.CREATE.KV_#{name}", payload)
147
+ result = Utils::Json.parse(resp.data, symbolize_names: false)
148
+ if result&.dig("error")
149
+ msg = result.dig("error", "description").to_s
150
+ # Two worker processes starting simultaneously can both attempt creation.
151
+ # If another process won the race, fall back to looking up the existing bucket.
152
+ raise NATS::JetStream::Error, msg unless msg.match?(/already in use|already exists/i)
153
+ end
154
+ js.key_value(name)
155
+ end
110
156
  end
111
157
  end
data/lib/cosmo/config.rb CHANGED
@@ -31,11 +31,20 @@ module Cosmo
31
31
  end
32
32
 
33
33
  config[:setup]&.each_key do |type|
34
+ next if type == :cron
35
+
34
36
  config[:setup][type]&.each_key do |name|
35
37
  c = config[:setup][type][name]
36
38
  c[:max_age] = c[:max_age].to_i * NANO if c[:max_age]
37
39
  c[:duplicate_window] = c[:duplicate_window].to_i * NANO if c[:duplicate_window]
38
40
  c[:subjects] = c[:subjects].map { |s| format(s, name: name) } if c[:subjects]
41
+
42
+ next unless type == :jobs # Every jobs stream supports NATS 2.14 message scheduling.
43
+
44
+ c[:allow_msg_schedules] = true
45
+ cron_subject = "#{API::Cron::Entry::SUBJECT_PREFIX}.#{name}.>"
46
+ c[:subjects] = Array(c[:subjects])
47
+ c[:subjects] << cron_subject unless c[:subjects].include?(cron_subject)
39
48
  end
40
49
  end
41
50
  end
@@ -5,7 +5,7 @@ require "json"
5
5
  module Cosmo
6
6
  module Job
7
7
  class Data
8
- DEFAULTS = { stream: :default, retry: 3, dead: true }.freeze
8
+ DEFAULTS = { stream: :default, retry: 3, dead: true, limit: nil }.freeze
9
9
 
10
10
  attr_reader :jid
11
11