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.
- checksums.yaml +4 -4
- data/README.md +208 -156
- data/lib/cosmo/active_job/adapter.rb +46 -0
- data/lib/cosmo/active_job/executor.rb +16 -0
- data/lib/cosmo/active_job/options.rb +50 -0
- data/lib/cosmo/active_job.rb +29 -0
- data/lib/cosmo/api/busy.rb +2 -2
- data/lib/cosmo/api/counter.rb +2 -2
- data/lib/cosmo/api/cron/entry.rb +99 -0
- data/lib/cosmo/api/cron.rb +118 -0
- data/lib/cosmo/api/kv.rb +35 -13
- data/lib/cosmo/api/stream.rb +10 -5
- data/lib/cosmo/api.rb +1 -0
- data/lib/cosmo/cli.rb +27 -10
- data/lib/cosmo/client.rb +48 -2
- data/lib/cosmo/config.rb +9 -0
- data/lib/cosmo/job/data.rb +1 -1
- data/lib/cosmo/job/limit.rb +51 -0
- data/lib/cosmo/job/processor.rb +49 -5
- data/lib/cosmo/job.rb +51 -2
- data/lib/cosmo/processor.rb +1 -1
- data/lib/cosmo/railtie.rb +21 -0
- data/lib/cosmo/stream/processor.rb +2 -2
- data/lib/cosmo/stream.rb +2 -1
- data/lib/cosmo/utils/hash.rb +13 -0
- data/lib/cosmo/utils/overrides.rb +1 -1
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +42 -0
- data/lib/cosmo/web/controllers/crons.rb +41 -0
- data/lib/cosmo/web/controllers/jobs.rb +7 -3
- data/lib/cosmo/web/controllers/streams.rb +1 -1
- data/lib/cosmo/web/helpers/application.rb +4 -0
- data/lib/cosmo/web/views/actions/index.erb +1 -1
- data/lib/cosmo/web/views/crons/_table.erb +58 -0
- data/lib/cosmo/web/views/crons/index.erb +10 -0
- data/lib/cosmo/web/views/jobs/_busy.erb +54 -49
- data/lib/cosmo/web/views/jobs/_dead.erb +70 -65
- data/lib/cosmo/web/views/jobs/_enqueued.erb +82 -56
- data/lib/cosmo/web/views/jobs/_scheduled.erb +53 -48
- data/lib/cosmo/web/views/jobs/_tabs.erb +6 -0
- data/lib/cosmo/web/views/jobs/busy.erb +8 -6
- data/lib/cosmo/web/views/jobs/dead.erb +6 -5
- data/lib/cosmo/web/views/jobs/enqueued.erb +8 -6
- data/lib/cosmo/web/views/jobs/index.erb +1 -1
- data/lib/cosmo/web/views/jobs/scheduled.erb +6 -5
- data/lib/cosmo/web/views/layout.erb +1 -1
- data/lib/cosmo/web.rb +5 -0
- data/lib/cosmo.rb +1 -0
- data/sig/cosmo/active_job/adapter.rbs +13 -0
- data/sig/cosmo/active_job/executor.rbs +9 -0
- data/sig/cosmo/active_job/options.rbs +14 -0
- data/sig/cosmo/api/cron/entry.rbs +30 -0
- data/sig/cosmo/api/cron.rbs +25 -0
- data/sig/cosmo/api/kv.rbs +4 -6
- data/sig/cosmo/client.rbs +9 -1
- data/sig/cosmo/job/data.rbs +1 -1
- data/sig/cosmo/job/limit.rbs +18 -0
- data/sig/cosmo/job/processor.rbs +3 -1
- data/sig/cosmo/job.rbs +9 -4
- data/sig/cosmo/railtie.rbs +4 -0
- data/sig/cosmo/utils/hash.rbs +4 -0
- 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
|
data/lib/cosmo/api/busy.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Cosmo
|
|
|
7
7
|
class Busy
|
|
8
8
|
TTL = 70
|
|
9
9
|
HEARTBEAT = 30
|
|
10
|
-
BUCKET = "
|
|
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
|
data/lib/cosmo/api/counter.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Cosmo
|
|
4
4
|
module API
|
|
5
5
|
class Counter
|
|
6
|
-
STREAM_NAME = "
|
|
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)
|
|
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
|
-
|
|
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
|
data/lib/cosmo/api/stream.rb
CHANGED
|
@@ -35,8 +35,10 @@ module Cosmo
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def total
|
|
38
|
-
info[:state].messages.to_i
|
|
39
|
-
|
|
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
|
-
|
|
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
data/lib/cosmo/cli.rb
CHANGED
|
@@ -4,7 +4,8 @@ require "yaml"
|
|
|
4
4
|
require "optparse"
|
|
5
5
|
|
|
6
6
|
module Cosmo
|
|
7
|
-
|
|
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)
|
|
75
|
-
OptionParser.new do |o|
|
|
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", "
|
|
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]&.
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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
|