cosmonats 0.2.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +300 -187
  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 +36 -14
  12. data/lib/cosmo/api/stream.rb +27 -9
  13. data/lib/cosmo/api.rb +1 -0
  14. data/lib/cosmo/cli.rb +27 -9
  15. data/lib/cosmo/client.rb +75 -5
  16. data/lib/cosmo/config.rb +14 -32
  17. data/lib/cosmo/engine.rb +1 -1
  18. data/lib/cosmo/job/data.rb +1 -1
  19. data/lib/cosmo/job/limit.rb +51 -0
  20. data/lib/cosmo/job/processor.rb +82 -63
  21. data/lib/cosmo/job.rb +51 -2
  22. data/lib/cosmo/logger.rb +4 -1
  23. data/lib/cosmo/processor.rb +108 -0
  24. data/lib/cosmo/railtie.rb +21 -0
  25. data/lib/cosmo/stream/processor.rb +24 -60
  26. data/lib/cosmo/stream.rb +4 -3
  27. data/lib/cosmo/utils/hash.rb +13 -24
  28. data/lib/cosmo/utils/overrides.rb +1 -1
  29. data/lib/cosmo/utils/ttl_cache.rb +44 -0
  30. data/lib/cosmo/utils.rb +1 -0
  31. data/lib/cosmo/version.rb +1 -1
  32. data/lib/cosmo/web/assets/app.css +88 -0
  33. data/lib/cosmo/web/controllers/crons.rb +41 -0
  34. data/lib/cosmo/web/controllers/jobs.rb +7 -3
  35. data/lib/cosmo/web/controllers/streams.rb +36 -10
  36. data/lib/cosmo/web/helpers/application.rb +17 -2
  37. data/lib/cosmo/web/views/actions/index.erb +1 -1
  38. data/lib/cosmo/web/views/crons/_table.erb +58 -0
  39. data/lib/cosmo/web/views/crons/index.erb +10 -0
  40. data/lib/cosmo/web/views/jobs/_busy.erb +54 -49
  41. data/lib/cosmo/web/views/jobs/_dead.erb +70 -65
  42. data/lib/cosmo/web/views/jobs/_enqueued.erb +82 -56
  43. data/lib/cosmo/web/views/jobs/_scheduled.erb +53 -48
  44. data/lib/cosmo/web/views/jobs/_tabs.erb +6 -0
  45. data/lib/cosmo/web/views/jobs/busy.erb +8 -6
  46. data/lib/cosmo/web/views/jobs/dead.erb +6 -5
  47. data/lib/cosmo/web/views/jobs/enqueued.erb +8 -6
  48. data/lib/cosmo/web/views/jobs/index.erb +1 -1
  49. data/lib/cosmo/web/views/jobs/scheduled.erb +6 -5
  50. data/lib/cosmo/web/views/layout.erb +1 -1
  51. data/lib/cosmo/web/views/streams/_info.erb +3 -0
  52. data/lib/cosmo/web/views/streams/_pause_banner.erb +17 -0
  53. data/lib/cosmo/web/views/streams/_stream_row.erb +42 -0
  54. data/lib/cosmo/web/views/streams/_table.erb +4 -21
  55. data/lib/cosmo/web.rb +7 -0
  56. data/lib/cosmo.rb +1 -0
  57. data/sig/cosmo/active_job/adapter.rbs +13 -0
  58. data/sig/cosmo/active_job/executor.rbs +9 -0
  59. data/sig/cosmo/active_job/options.rbs +14 -0
  60. data/sig/cosmo/api/cron/entry.rbs +30 -0
  61. data/sig/cosmo/api/cron.rbs +25 -0
  62. data/sig/cosmo/api/kv.rbs +4 -6
  63. data/sig/cosmo/api/stream.rbs +7 -1
  64. data/sig/cosmo/client.rbs +20 -4
  65. data/sig/cosmo/config.rbs +3 -15
  66. data/sig/cosmo/job/data.rbs +1 -1
  67. data/sig/cosmo/job/limit.rbs +18 -0
  68. data/sig/cosmo/job/processor.rbs +19 -9
  69. data/sig/cosmo/job.rbs +9 -4
  70. data/sig/cosmo/processor.rbs +26 -0
  71. data/sig/cosmo/railtie.rbs +4 -0
  72. data/sig/cosmo/stream/processor.rbs +4 -10
  73. data/sig/cosmo/utils/hash.rbs +4 -8
  74. data/sig/cosmo/utils/ttl_cache.rbs +20 -0
  75. metadata +25 -3
  76. data/lib/cosmo/defaults.yml +0 -70
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module ActiveJobAdapter
5
+ # ActiveJob queue adapter that enqueues jobs via NATS JetStream.
6
+ #
7
+ # Usage:
8
+ # config.active_job.queue_adapter = :cosmonats
9
+ # # or explicitly:
10
+ # config.active_job.queue_adapter = Cosmo::ActiveJobAdapter::Adapter.new
11
+ #
12
+ # The ActiveJob queue name maps directly to the Cosmo stream name.
13
+ class Adapter
14
+ # Enqueue a job to be run as soon as possible.
15
+ # @param job [ActiveJob::Base]
16
+ def enqueue(job)
17
+ publish(job, nil)
18
+ end
19
+
20
+ # Enqueue a job to be run at (or after) a given time.
21
+ # @param job [ActiveJob::Base]
22
+ # @param timestamp [Numeric] Unix timestamp (seconds, float)
23
+ def enqueue_at(job, timestamp)
24
+ publish(job, timestamp)
25
+ end
26
+
27
+ private
28
+
29
+ def publish(job, timestamp)
30
+ cosmo_opts = job_cosmo_options(job)
31
+ stream = cosmo_opts.delete(:stream) || job.queue_name.to_sym
32
+ options = { stream: stream }.merge(cosmo_opts)
33
+ options[:at] = timestamp if timestamp
34
+
35
+ data = Job::Data.new(Executor.name, [job.serialize], options)
36
+ Publisher.publish_job(data)
37
+ end
38
+
39
+ # Returns Cosmo-specific options declared on the job class via
40
+ # +cosmo_options+, falling back to an empty hash.
41
+ def job_cosmo_options(job)
42
+ job.class.respond_to?(:get_cosmo_options) ? job.class.get_cosmo_options : {}
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module ActiveJobAdapter
5
+ # Cosmo::Job that deserializes and executes an ActiveJob payload
6
+ class Executor
7
+ include Cosmo::Job
8
+
9
+ options stream: :default
10
+
11
+ def perform(job_data)
12
+ ::ActiveJob::Base.execute(Utils::Hash.stringify_keys(job_data))
13
+ end
14
+ end
15
+ end
16
+ end
@@ -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
 
@@ -48,16 +76,10 @@ module Cosmo
48
76
 
49
77
  def count
50
78
  keys.size
51
- rescue NATS::KeyValue::NoKeysFoundError
79
+ rescue NATS::KeyValue::NoKeysFoundError, NATS::JetStream::Error::NotFound
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
@@ -10,12 +10,13 @@ module Cosmo
10
10
  include Enumerable
11
11
 
12
12
  def self.all
13
- client.list_streams.filter_map { new(_1) }
13
+ client.list_streams.map { new(_1.dig("config", "name")) }
14
14
  end
15
15
 
16
16
  def self.jobs
17
- names = Config[:setup][:jobs].keys - %i[scheduled dead]
18
- all.select { names.include?(_1.name.to_sym) }
17
+ client.list_streams.select { _1.dig("config", "metadata", "_cosmo.type") == "jobs" }
18
+ .reject { %w[scheduled dead].include?(_1.dig("config", "name")) }
19
+ .map { new(_1.dig("config", "name")) }
19
20
  end
20
21
 
21
22
  def self.client
@@ -34,14 +35,16 @@ module Cosmo
34
35
  end
35
36
 
36
37
  def total
37
- info[:state].messages.to_i
38
- 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
39
42
  0
40
43
  end
41
44
  alias size total
42
45
 
43
46
  def retries
44
- client.list_consumers(name).sum { it["num_redelivered"].to_i }
47
+ client.list_consumers(name).sum { _1["num_redelivered"].to_i }
45
48
  end
46
49
 
47
50
  def each
@@ -55,10 +58,10 @@ module Cosmo
55
58
  break if current > last
56
59
 
57
60
  job = message(current)
58
- break unless job
61
+ current += 1
62
+ next unless job
59
63
 
60
64
  yield job
61
- current += 1
62
65
  end
63
66
  end
64
67
 
@@ -83,7 +86,10 @@ module Cosmo
83
86
  end
84
87
 
85
88
  def message(seq)
86
- 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
87
93
  rescue NATS::JetStream::Error::NotFound
88
94
  # nop, acked/nacked
89
95
  end
@@ -100,6 +106,18 @@ module Cosmo
100
106
  client.delete_message(name, seq)
101
107
  end
102
108
 
109
+ def pause!
110
+ client.pause_stream(name)
111
+ end
112
+
113
+ def unpause!
114
+ client.unpause_stream(name)
115
+ end
116
+
117
+ def paused?
118
+ client.stream_paused?(name)
119
+ end
120
+
103
121
  private
104
122
 
105
123
  def client
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,19 +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
- Client.instance.create_stream(name, config)
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
110
119
  end
111
120
  end
112
121
 
113
- puts "Cosmo streams were created/updated"
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."
114
131
  exit(0)
115
132
  end
116
133
 
@@ -233,4 +250,5 @@ module Cosmo
233
250
  end
234
251
  # rubocop:enable Layout/TrailingWhitespace,Lint/IneffectiveAccessModifier
235
252
  end
253
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
236
254
  end