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.
- checksums.yaml +4 -4
- data/README.md +300 -187
- 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 +36 -14
- data/lib/cosmo/api/stream.rb +27 -9
- data/lib/cosmo/api.rb +1 -0
- data/lib/cosmo/cli.rb +27 -9
- data/lib/cosmo/client.rb +75 -5
- data/lib/cosmo/config.rb +14 -32
- data/lib/cosmo/engine.rb +1 -1
- data/lib/cosmo/job/data.rb +1 -1
- data/lib/cosmo/job/limit.rb +51 -0
- data/lib/cosmo/job/processor.rb +82 -63
- data/lib/cosmo/job.rb +51 -2
- data/lib/cosmo/logger.rb +4 -1
- data/lib/cosmo/processor.rb +108 -0
- data/lib/cosmo/railtie.rb +21 -0
- data/lib/cosmo/stream/processor.rb +24 -60
- data/lib/cosmo/stream.rb +4 -3
- data/lib/cosmo/utils/hash.rb +13 -24
- data/lib/cosmo/utils/overrides.rb +1 -1
- data/lib/cosmo/utils/ttl_cache.rb +44 -0
- data/lib/cosmo/utils.rb +1 -0
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +88 -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 +36 -10
- data/lib/cosmo/web/helpers/application.rb +17 -2
- 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/views/streams/_info.erb +3 -0
- data/lib/cosmo/web/views/streams/_pause_banner.erb +17 -0
- data/lib/cosmo/web/views/streams/_stream_row.erb +42 -0
- data/lib/cosmo/web/views/streams/_table.erb +4 -21
- data/lib/cosmo/web.rb +7 -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/api/stream.rbs +7 -1
- data/sig/cosmo/client.rbs +20 -4
- data/sig/cosmo/config.rbs +3 -15
- data/sig/cosmo/job/data.rbs +1 -1
- data/sig/cosmo/job/limit.rbs +18 -0
- data/sig/cosmo/job/processor.rbs +19 -9
- data/sig/cosmo/job.rbs +9 -4
- data/sig/cosmo/processor.rbs +26 -0
- data/sig/cosmo/railtie.rbs +4 -0
- data/sig/cosmo/stream/processor.rbs +4 -10
- data/sig/cosmo/utils/hash.rbs +4 -8
- data/sig/cosmo/utils/ttl_cache.rbs +20 -0
- metadata +25 -3
- 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
|
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
|
|
|
@@ -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
|
data/lib/cosmo/api/stream.rb
CHANGED
|
@@ -10,12 +10,13 @@ module Cosmo
|
|
|
10
10
|
include Enumerable
|
|
11
11
|
|
|
12
12
|
def self.all
|
|
13
|
-
client.list_streams.
|
|
13
|
+
client.list_streams.map { new(_1.dig("config", "name")) }
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def self.jobs
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
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,19 +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
|
-
|
|
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
|
|
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
|