cosmonats 0.1.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 +7 -0
- data/LICENSE.txt +169 -0
- data/README.md +515 -0
- data/bin/cosmo +7 -0
- data/lib/cosmo/cli.rb +201 -0
- data/lib/cosmo/client.rb +54 -0
- data/lib/cosmo/config.rb +101 -0
- data/lib/cosmo/defaults.yml +69 -0
- data/lib/cosmo/engine.rb +46 -0
- data/lib/cosmo/job/data.rb +74 -0
- data/lib/cosmo/job/processor.rb +132 -0
- data/lib/cosmo/job.rb +67 -0
- data/lib/cosmo/logger.rb +66 -0
- data/lib/cosmo/processor.rb +56 -0
- data/lib/cosmo/publisher.rb +38 -0
- data/lib/cosmo/stream/data.rb +21 -0
- data/lib/cosmo/stream/message.rb +31 -0
- data/lib/cosmo/stream/processor.rb +94 -0
- data/lib/cosmo/stream/serializer.rb +19 -0
- data/lib/cosmo/stream.rb +76 -0
- data/lib/cosmo/utils/hash.rb +66 -0
- data/lib/cosmo/utils/json.rb +23 -0
- data/lib/cosmo/utils/signal.rb +24 -0
- data/lib/cosmo/utils/stopwatch.rb +32 -0
- data/lib/cosmo/utils/string.rb +24 -0
- data/lib/cosmo/utils/thread_pool.rb +41 -0
- data/lib/cosmo/version.rb +5 -0
- data/lib/cosmo.rb +39 -0
- data/lib/cosmonats.rb +3 -0
- data/sig/cosmo/cli.rbs +25 -0
- data/sig/cosmo/client.rbs +30 -0
- data/sig/cosmo/config.rbs +48 -0
- data/sig/cosmo/engine.rbs +21 -0
- data/sig/cosmo/job/data.rbs +35 -0
- data/sig/cosmo/job/processor.rbs +23 -0
- data/sig/cosmo/job.rbs +35 -0
- data/sig/cosmo/logger.rbs +39 -0
- data/sig/cosmo/message.rbs +38 -0
- data/sig/cosmo/processor.rbs +29 -0
- data/sig/cosmo/publisher.rbs +21 -0
- data/sig/cosmo/stream/data.rbs +7 -0
- data/sig/cosmo/stream/processor.rbs +26 -0
- data/sig/cosmo/stream/serializer.rbs +13 -0
- data/sig/cosmo/stream.rbs +38 -0
- data/sig/cosmo/utils/hash.rbs +25 -0
- data/sig/cosmo/utils/json.rbs +13 -0
- data/sig/cosmo/utils/signal.rbs +15 -0
- data/sig/cosmo/utils/stopwatch.rbs +19 -0
- data/sig/cosmo/utils/string.rbs +13 -0
- data/sig/cosmo/utils/thread_pool.rbs +18 -0
- data/sig/cosmo.rbs +20 -0
- metadata +125 -0
data/lib/cosmo/cli.rb
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "optparse"
|
|
5
|
+
|
|
6
|
+
module Cosmo
|
|
7
|
+
class CLI # rubocop:disable Metrics/ClassLength
|
|
8
|
+
def self.run
|
|
9
|
+
instance.run
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.instance
|
|
13
|
+
@instance ||= new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
flags, command, _options = parse
|
|
18
|
+
load_config(flags[:config_file])
|
|
19
|
+
puts self.class.banner
|
|
20
|
+
require_files(flags[:require])
|
|
21
|
+
Engine.run(command)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def parse
|
|
27
|
+
flags = {}
|
|
28
|
+
parser = flags_parser(flags)
|
|
29
|
+
parser.order!
|
|
30
|
+
|
|
31
|
+
options = {}
|
|
32
|
+
command = ARGV.shift
|
|
33
|
+
parser = options_parser(command, options)
|
|
34
|
+
parser&.order!
|
|
35
|
+
|
|
36
|
+
[flags, command, options]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def load_config(path)
|
|
40
|
+
raise ConfigNotFoundError, path if path && !File.exist?(path)
|
|
41
|
+
|
|
42
|
+
unless path
|
|
43
|
+
# Try default path
|
|
44
|
+
default_path = File.expand_path(Config::DEFAULT_PATH)
|
|
45
|
+
path = default_path if File.exist?(default_path)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Config.load(path)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def require_files(path)
|
|
52
|
+
return unless path
|
|
53
|
+
|
|
54
|
+
if File.directory?(path)
|
|
55
|
+
files = Dir[File.expand_path("#{path}/*.rb")]
|
|
56
|
+
files.each { |f| require f }
|
|
57
|
+
else
|
|
58
|
+
require File.expand_path(path)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def flags_parser(flags) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
63
|
+
OptionParser.new do |o| # rubocop:disable Metrics/BlockLength
|
|
64
|
+
o.banner = "Usage: cosmo [flags] [command] [options]"
|
|
65
|
+
o.separator ""
|
|
66
|
+
o.separator "Command:"
|
|
67
|
+
o.separator " jobs Run jobs"
|
|
68
|
+
o.separator " streams Run streams"
|
|
69
|
+
o.separator " actions Run actions"
|
|
70
|
+
o.separator ""
|
|
71
|
+
o.separator "Flags:"
|
|
72
|
+
|
|
73
|
+
o.on "-c", "--concurrency INT", Integer, "Threads to use" do |arg|
|
|
74
|
+
flags[:concurrency] = arg
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
o.on "-r", "--require PATH|DIR", "Location of files to require" do |arg|
|
|
78
|
+
flags[:require] = arg
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
o.on "-t", "--timeout NUM", Integer, "Shutdown timeout" do |arg|
|
|
82
|
+
flags[:timeout] = arg
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
o.on "-C", "--config PATH", "Path to config file" do |arg|
|
|
86
|
+
flags[:config_file] = arg
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
o.on "-S", "--setup", "Load config, create streams and exit" do
|
|
90
|
+
load_config(flags[:config_file])
|
|
91
|
+
|
|
92
|
+
Config[:streams].each do |name, config|
|
|
93
|
+
Client.instance.stream_info(name)
|
|
94
|
+
rescue NATS::JetStream::Error::NotFound
|
|
95
|
+
Client.instance.create_stream(name, config)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
puts "Cosmo streams were created/updated"
|
|
99
|
+
exit(0)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
o.on_tail "-v", "--version", "Print version" do
|
|
103
|
+
puts "Cosmo #{VERSION}"
|
|
104
|
+
exit(0)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
o.on_tail "-h", "--help", "Show help" do
|
|
108
|
+
puts o
|
|
109
|
+
exit(0)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def options_parser(command, options) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
115
|
+
case command
|
|
116
|
+
when "jobs"
|
|
117
|
+
OptionParser.new do |o|
|
|
118
|
+
o.banner = "Usage: cosmo jobs [options]"
|
|
119
|
+
|
|
120
|
+
o.on "--stream NAME", "Job's stream" do |arg|
|
|
121
|
+
options[:stream] = arg
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
o.on "--subject NAME", "Job's subject" do |arg|
|
|
125
|
+
options[:subject] = arg
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
when "streams"
|
|
129
|
+
OptionParser.new do |o|
|
|
130
|
+
o.banner = "Usage: cosmo streams [options]"
|
|
131
|
+
|
|
132
|
+
o.on "--stream NAME", "Specify stream name" do |arg|
|
|
133
|
+
options[:stream] = arg
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
o.on "--subject NAME", "Specify subject name" do |arg|
|
|
137
|
+
options[:subject] = arg
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
o.on "--consumer_name NAME", "Specify consumer name" do |arg|
|
|
141
|
+
options[:consumer_name] = arg
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
o.on "--batch_size NUM", Integer, "Number of messages in the batch" do |arg|
|
|
145
|
+
options[:batch_size] = arg
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
when "actions"
|
|
149
|
+
OptionParser.new do |o|
|
|
150
|
+
o.banner = "Usage: cosmo actions [options]"
|
|
151
|
+
|
|
152
|
+
o.on "-n", "--nop", "Do nothing and exit" do
|
|
153
|
+
exit(0)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# rubocop:disable Layout/TrailingWhitespace,Lint/IneffectiveAccessModifier
|
|
160
|
+
def self.banner
|
|
161
|
+
<<-TEXT
|
|
162
|
+
.#%+:
|
|
163
|
+
==-. +.
|
|
164
|
+
+: .::::. :*-
|
|
165
|
+
.=%%%%%%%%%%%%%#-
|
|
166
|
+
.#%%%%%%%%##*+===+*#%%:
|
|
167
|
+
:##%%%%#: :-::...::::. -%.
|
|
168
|
+
+%%%** :. :+. -=.%: -
|
|
169
|
+
*%%%%: .-%%%# ++ ---%. -= :==-
|
|
170
|
+
:%%%- *%%%% *- %:#+ =%%%. .====:
|
|
171
|
+
.%@%+.##. #%%: -+ =-=- *%%: . :=
|
|
172
|
+
=*%%%=-#. :: =-: *%-%%%%%#
|
|
173
|
+
.%=##* #- %. :%%-+++%%%:
|
|
174
|
+
+=*+= +%. .*. .=+.%%-#%%#*+:
|
|
175
|
+
===: =*%= .*+ *%+%%% -%*:%%%%%: .
|
|
176
|
+
=***%*. .:#*: .%%*+%%%#.+%+. . =
|
|
177
|
+
-%#-: -*########+ .: +%%%=%%%*++:
|
|
178
|
+
.##:---. :%%- *: +%%%%%%%%%%%##. -#%%*= =-
|
|
179
|
+
*#:-: :%%%%+%%= --%%#. -#%%%%+=#- =: .::::::::::::::
|
|
180
|
+
:#- =%%%%%%%+++. +*%=-%%%%#-.. ..:::::::::::::..
|
|
181
|
+
+***+:=***:: ==:. ....::..
|
|
182
|
+
-%%%%%+%%- .... . +##%%= .#%%%%%%%#.
|
|
183
|
+
.%%%%##=%%- :+#%%%#. *%%%%+ -%%%%%= .%%%%%%%%%%%
|
|
184
|
+
-+-- -=#%#+: +%%%%%%%#. *%%%%%+ #%%%%%= =%%%* %%%%.
|
|
185
|
+
.-+###. *%%%%%%%%+ -%%%%+. *%%%%%%:*%%%%%%+ -%%%* %%%%.
|
|
186
|
+
.*%%%%%%% %%%%+.=%%%# =%%%%- *%%%%%%%%%%*%%%+ -%%%* %%%%.
|
|
187
|
+
=%%%%%#=:. :%%%# .%%%# #%%%%%%%: +%%%-*%%%%:=%%%+ -%%%* %%%%.
|
|
188
|
+
-%%%%- .%%%# %%%% .#%%%%%= +%%%- #%%= -%%%+ -%%%* .%%%%.
|
|
189
|
+
*%%%# .%%%#. %%%% +%%%* +%%%- -%%%+ #%%%###%%%*
|
|
190
|
+
%%%%* %%%#. #%%%..****#####- =###- -###+ =######*.
|
|
191
|
+
#%%%# #%%%*+*#%%* .########: =#*=:
|
|
192
|
+
+%%%%- .=+ .########= :----. .:::--====++++********###
|
|
193
|
+
.#%#########: :==-: ..:--=====---::::..
|
|
194
|
+
.########+. .:--=--::.
|
|
195
|
+
:--. .---:.
|
|
196
|
+
:.
|
|
197
|
+
TEXT
|
|
198
|
+
end
|
|
199
|
+
# rubocop:enable Layout/TrailingWhitespace,Lint/IneffectiveAccessModifier
|
|
200
|
+
end
|
|
201
|
+
end
|
data/lib/cosmo/client.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nats/client"
|
|
4
|
+
|
|
5
|
+
module Cosmo
|
|
6
|
+
class Client
|
|
7
|
+
def self.instance
|
|
8
|
+
@instance ||= Client.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
attr_reader :nc, :js
|
|
12
|
+
|
|
13
|
+
def initialize(nats_url: ENV.fetch("NATS_URL", "nats://localhost:4222"))
|
|
14
|
+
@nc = NATS.connect(nats_url)
|
|
15
|
+
@js = @nc.jetstream
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def publish(subject, payload, **params)
|
|
19
|
+
js.publish(subject, payload, **params)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def subscribe(subject, consumer_name, config)
|
|
23
|
+
js.pull_subscribe(subject, consumer_name, config: config)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def stream_info(name)
|
|
27
|
+
js.stream_info(name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def create_stream(name, config)
|
|
31
|
+
js.add_stream(name: name, **config)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def delete_stream(name, params = {})
|
|
35
|
+
js.delete_stream(name, params)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def list_streams
|
|
39
|
+
response = nc.request("$JS.API.STREAM.LIST", "")
|
|
40
|
+
data = Utils::Json.parse(response.data, symbolize_names: false)
|
|
41
|
+
return [] if data.nil? || data["streams"].nil?
|
|
42
|
+
|
|
43
|
+
data["streams"].filter_map { _1.dig("config", "name") }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def get_message(name, seq)
|
|
47
|
+
js.get_msg(name, seq: seq)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def close
|
|
51
|
+
nc.close
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/cosmo/config.rb
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "forwardable"
|
|
5
|
+
|
|
6
|
+
module Cosmo
|
|
7
|
+
class Config
|
|
8
|
+
NANO = 1_000_000_000
|
|
9
|
+
DEFAULT_PATH = "config/cosmo.yml"
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
extend Forwardable
|
|
13
|
+
|
|
14
|
+
delegate %i[[] fetch dig to_h set load] => :instance
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.parse_file(path)
|
|
18
|
+
YAML.load_file(path, aliases: true).tap { normalize!(_1) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.normalize!(config) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
22
|
+
Utils::Hash.symbolize_keys!(config)
|
|
23
|
+
|
|
24
|
+
config[:consumers]&.each_key do |name|
|
|
25
|
+
config[:consumers][name].each do |stream_name, c|
|
|
26
|
+
next unless c
|
|
27
|
+
|
|
28
|
+
c[:subject] = format(c[:subject], { name: stream_name }) if c[:subject]
|
|
29
|
+
c[:subjects] = c[:subjects].map { |s| format(s, name: stream_name) } if c[:subjects]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
config[:streams]&.each_key do |name|
|
|
34
|
+
c = config[:streams][name]
|
|
35
|
+
c[:max_age] = c[:max_age].to_i * NANO if c[:max_age]
|
|
36
|
+
c[:duplicate_window] = c[:duplicate_window].to_i * NANO if c[:duplicate_window]
|
|
37
|
+
c[:subjects] = c[:subjects].map { |s| format(s, name: name) } if c[:subjects]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.deliver_policy(start_position)
|
|
42
|
+
case start_position
|
|
43
|
+
when "last", :last
|
|
44
|
+
{ deliver_policy: "last" }
|
|
45
|
+
when "new", :new
|
|
46
|
+
{ deliver_policy: "new" }
|
|
47
|
+
when Time
|
|
48
|
+
{ deliver_policy: "by_start_time", opt_start_time: start_position.iso8601 }
|
|
49
|
+
when String
|
|
50
|
+
{ deliver_policy: "by_start_time", opt_start_time: start_position }
|
|
51
|
+
else
|
|
52
|
+
{ deliver_policy: "all" }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.instance
|
|
57
|
+
@instance ||= new
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.system
|
|
61
|
+
@system ||= {}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def initialize
|
|
65
|
+
@config = nil
|
|
66
|
+
@system = {}
|
|
67
|
+
@defaults = self.class.parse_file(File.expand_path("defaults.yml", __dir__))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def [](key)
|
|
71
|
+
dig(key)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def fetch(key, default = nil)
|
|
75
|
+
return @config.fetch(key, default) if @config && Utils::Hash.keys?(@config, key)
|
|
76
|
+
|
|
77
|
+
@defaults.fetch(key, default)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def dig(*keys)
|
|
81
|
+
return @config&.dig(*keys) if @config && Utils::Hash.keys?(@config, *keys)
|
|
82
|
+
|
|
83
|
+
@defaults.dig(*keys)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def to_h
|
|
87
|
+
Utils::Hash.merge(@defaults, @config)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def set(...)
|
|
91
|
+
@config ||= {}
|
|
92
|
+
Utils::Hash.set(@config, ...)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def load(path = nil)
|
|
96
|
+
return unless path
|
|
97
|
+
|
|
98
|
+
@config = self.class.parse_file(path)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
timeout: 25
|
|
2
|
+
max_retries: &max_retries 3
|
|
3
|
+
concurrency: &concurrency 1
|
|
4
|
+
|
|
5
|
+
consumers:
|
|
6
|
+
jobs:
|
|
7
|
+
critical:
|
|
8
|
+
<<: &config
|
|
9
|
+
ack_policy: explicit # each individual message must be acknowledged
|
|
10
|
+
max_deliver: *max_retries # max number of times a message delivery will be attempted
|
|
11
|
+
max_ack_pending: 3 # maximum number of messages w/o ack
|
|
12
|
+
ack_wait: 60 # duration server waits for ack of message once it's delivered
|
|
13
|
+
subject: jobs.%{name}.>
|
|
14
|
+
priority: 50
|
|
15
|
+
high:
|
|
16
|
+
<<: *config
|
|
17
|
+
priority: 30
|
|
18
|
+
default:
|
|
19
|
+
<<: *config
|
|
20
|
+
priority: 15
|
|
21
|
+
low:
|
|
22
|
+
<<: *config
|
|
23
|
+
priority: 5
|
|
24
|
+
scheduled:
|
|
25
|
+
<<: *config
|
|
26
|
+
max_deliver: 1
|
|
27
|
+
max_ack_pending: 100
|
|
28
|
+
ack_wait: 10
|
|
29
|
+
|
|
30
|
+
streams:
|
|
31
|
+
critical:
|
|
32
|
+
<<: &config
|
|
33
|
+
storage: file
|
|
34
|
+
retention: workqueue
|
|
35
|
+
duplicate_window: 120 # 2m
|
|
36
|
+
discard: old
|
|
37
|
+
allow_direct: true
|
|
38
|
+
subjects:
|
|
39
|
+
- jobs.%{name}.>
|
|
40
|
+
description: Very critical priority jobs
|
|
41
|
+
high:
|
|
42
|
+
<<: *config
|
|
43
|
+
description: Higher priority jobs
|
|
44
|
+
default:
|
|
45
|
+
<<: *config
|
|
46
|
+
description: Default priority jobs
|
|
47
|
+
low:
|
|
48
|
+
<<: *config
|
|
49
|
+
description: Lower priority jobs
|
|
50
|
+
scheduled:
|
|
51
|
+
<<: *config
|
|
52
|
+
description: Scheduled jobs
|
|
53
|
+
dead:
|
|
54
|
+
<<: *config
|
|
55
|
+
retention: limits
|
|
56
|
+
max_msgs: 10000
|
|
57
|
+
max_age: 604800 # 7d
|
|
58
|
+
description: Broken jobs (DLQ)
|
|
59
|
+
|
|
60
|
+
development:
|
|
61
|
+
verbose: false
|
|
62
|
+
concurrency: 1
|
|
63
|
+
|
|
64
|
+
staging:
|
|
65
|
+
verbose: true
|
|
66
|
+
concurrency: 3
|
|
67
|
+
|
|
68
|
+
production:
|
|
69
|
+
concurrency: 3
|
data/lib/cosmo/engine.rb
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent-ruby"
|
|
4
|
+
|
|
5
|
+
module Cosmo
|
|
6
|
+
class Engine
|
|
7
|
+
PROCESSORS = {
|
|
8
|
+
jobs: Job::Processor,
|
|
9
|
+
streams: Stream::Processor
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
def self.run(...)
|
|
13
|
+
instance.run(...)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.instance
|
|
17
|
+
@instance ||= new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize
|
|
21
|
+
@concurrency = Config.fetch(:concurrency, 1)
|
|
22
|
+
@pool = Utils::ThreadPool.new(@concurrency)
|
|
23
|
+
@running = Concurrent::AtomicBoolean.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run(type)
|
|
27
|
+
handler = Utils::Signal.trap(:INT, :TERM)
|
|
28
|
+
Logger.info "Starting processing, hit Ctrl-C to stop"
|
|
29
|
+
|
|
30
|
+
@processors = type && PROCESSORS.key?(type.to_sym) ? [PROCESSORS[type.to_sym]] : PROCESSORS.values
|
|
31
|
+
@processors = @processors.map { _1.run(@pool, @running) }
|
|
32
|
+
|
|
33
|
+
signal = handler.wait
|
|
34
|
+
Logger.info "Shutting down... (#{signal} received)"
|
|
35
|
+
shutdown
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def shutdown
|
|
39
|
+
@running.make_false
|
|
40
|
+
@pool.shutdown
|
|
41
|
+
Logger.info "Pausing to allow jobs to finish..."
|
|
42
|
+
@pool.wait_for_termination(Config[:timeout])
|
|
43
|
+
Logger.info "Bye!"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Cosmo
|
|
6
|
+
module Job
|
|
7
|
+
class Data
|
|
8
|
+
DEFAULTS = { stream: :default, retry: 3, dead: true }.freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :jid
|
|
11
|
+
|
|
12
|
+
def initialize(class_name, args, options = nil)
|
|
13
|
+
@class_name = class_name
|
|
14
|
+
@args = args
|
|
15
|
+
@options = Hash(options)
|
|
16
|
+
validate!
|
|
17
|
+
|
|
18
|
+
@at = @options[:at].to_i if @options[:at]
|
|
19
|
+
@at ||= Time.now.to_i + @options[:in].to_i if @options[:in]
|
|
20
|
+
@subject = @options[:subject] if @options[:subject]
|
|
21
|
+
|
|
22
|
+
@jid = SecureRandom.hex(12)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def stream(target: false)
|
|
26
|
+
return @options[:stream] if target
|
|
27
|
+
|
|
28
|
+
@at ? :scheduled : @options[:stream]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def subject(target: false)
|
|
32
|
+
["jobs", stream(target:).to_s, Utils::String.underscore(@class_name)]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def as_json
|
|
36
|
+
{
|
|
37
|
+
jid: jid,
|
|
38
|
+
class: @class_name,
|
|
39
|
+
args: @args,
|
|
40
|
+
retry: retries,
|
|
41
|
+
dead: dead
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def to_json(*_args)
|
|
46
|
+
Utils::Json.dump(as_json)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_args
|
|
50
|
+
headers = { "Nats-Msg-Id" => jid }
|
|
51
|
+
if @at
|
|
52
|
+
headers.merge!("X-Execute-At" => @at.to_i,
|
|
53
|
+
"X-Stream" => stream(target: true),
|
|
54
|
+
"X-Subject" => subject(target: true).join("."))
|
|
55
|
+
end
|
|
56
|
+
[@subject || subject.join("."), to_json, { stream: stream, header: headers }]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def validate!
|
|
62
|
+
raise ArgumentError, "stream is not provided" unless @options[:stream]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def retries
|
|
66
|
+
@options[:retry].nil? ? DEFAULTS[:retry] : @options[:retry]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def dead
|
|
70
|
+
@options[:dead].nil? ? DEFAULTS[:dead] : @options[:dead]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cosmo
|
|
4
|
+
module Job
|
|
5
|
+
class Processor < ::Cosmo::Processor
|
|
6
|
+
def initialize(pool, running)
|
|
7
|
+
super
|
|
8
|
+
@weights = []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def run_loop
|
|
14
|
+
Thread.new { work_loop }
|
|
15
|
+
Thread.new { schedule_loop }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def setup
|
|
19
|
+
jobs_config = Config.dig(:consumers, :jobs)
|
|
20
|
+
jobs_config&.each do |stream_name, config|
|
|
21
|
+
consumer_name = "consumer-#{stream_name}"
|
|
22
|
+
subject = config.delete(:subject)
|
|
23
|
+
priority = config.delete(:priority)
|
|
24
|
+
@weights += ([stream_name] * priority.to_i) if priority
|
|
25
|
+
@consumers[stream_name] = client.subscribe(subject, consumer_name, config)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def work_loop
|
|
30
|
+
while running?
|
|
31
|
+
@weights.shuffle.each do |stream_name|
|
|
32
|
+
break unless running?
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
timeout = ENV.fetch("COSMO_JOBS_FETCH_TIMEOUT", 0.1).to_f
|
|
36
|
+
@pool.post { fetch_messages(stream_name, batch_size: 1, timeout:) }
|
|
37
|
+
rescue Concurrent::RejectedExecutionError
|
|
38
|
+
break # pool doesn't accept new jobs, we are shutting down
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
break unless running?
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def schedule_loop # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
47
|
+
while running?
|
|
48
|
+
break unless running?
|
|
49
|
+
|
|
50
|
+
timeout = ENV.fetch("COSMO_JOBS_SCHEDULER_FETCH_TIMEOUT", 5).to_f
|
|
51
|
+
fetch_messages(:scheduled, batch_size: 100, timeout:) do |messages|
|
|
52
|
+
now = Time.now.to_i
|
|
53
|
+
messages.each do |message|
|
|
54
|
+
headers = message.header.except("X-Stream", "X-Subject", "X-Execute-At", "Nats-Expected-Stream")
|
|
55
|
+
stream, subject, execute_at = message.header.values_at("X-Stream", "X-Subject", "X-Execute-At")
|
|
56
|
+
headers["Nats-Expected-Stream"] = stream
|
|
57
|
+
execute_at = execute_at.to_i
|
|
58
|
+
|
|
59
|
+
if now >= execute_at
|
|
60
|
+
client.publish(subject, message.data, headers: headers)
|
|
61
|
+
message.ack
|
|
62
|
+
else
|
|
63
|
+
delay_ns = (execute_at - now) * 1_000_000_000
|
|
64
|
+
message.nak(delay: delay_ns)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
break unless running?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def process(messages) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
74
|
+
message = messages.first
|
|
75
|
+
Logger.debug "received messages #{messages.inspect}"
|
|
76
|
+
data = Utils::Json.parse(message.data)
|
|
77
|
+
unless data
|
|
78
|
+
Logger.debug ArgumentError.new("malformed payload")
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
worker_class = Utils::String.safe_constantize(data[:class])
|
|
83
|
+
unless worker_class
|
|
84
|
+
Logger.debug ArgumentError.new("#{data[:class]} class not found")
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
begin
|
|
89
|
+
sw = stopwatch
|
|
90
|
+
Logger.with(jid: data[:jid])
|
|
91
|
+
Logger.info "start"
|
|
92
|
+
instance = worker_class.new
|
|
93
|
+
instance.jid = data[:jid]
|
|
94
|
+
instance.perform(*data[:args])
|
|
95
|
+
message.ack
|
|
96
|
+
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "done" }
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
Logger.debug e
|
|
99
|
+
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
|
|
100
|
+
handle_failure(message, data)
|
|
101
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
102
|
+
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
|
|
103
|
+
raise
|
|
104
|
+
end
|
|
105
|
+
ensure
|
|
106
|
+
Logger.without(:jid)
|
|
107
|
+
Logger.debug "processed message #{message.inspect}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def handle_failure(message, data) # rubocop:disable Metrics/AbcSize
|
|
111
|
+
current_attempt = message.metadata.num_delivered
|
|
112
|
+
max_retries = data[:retry].to_i + 1
|
|
113
|
+
|
|
114
|
+
if current_attempt < max_retries
|
|
115
|
+
# NATS will auto-retry based on max_deliver with exponential backoff
|
|
116
|
+
delay_ns = ((current_attempt**4) + 15) * 1_000_000_000
|
|
117
|
+
message.nak(delay: delay_ns)
|
|
118
|
+
return
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
if data[:dead]
|
|
122
|
+
Client.instance.publish("jobs.dead.#{Utils::String.underscore(data[:class])}", message.data)
|
|
123
|
+
message.ack
|
|
124
|
+
Logger.debug "job moved #{data[:jid]} to DLQ"
|
|
125
|
+
else
|
|
126
|
+
message.term
|
|
127
|
+
Logger.debug "job dropped #{data[:jid]}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|