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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +169 -0
  3. data/README.md +515 -0
  4. data/bin/cosmo +7 -0
  5. data/lib/cosmo/cli.rb +201 -0
  6. data/lib/cosmo/client.rb +54 -0
  7. data/lib/cosmo/config.rb +101 -0
  8. data/lib/cosmo/defaults.yml +69 -0
  9. data/lib/cosmo/engine.rb +46 -0
  10. data/lib/cosmo/job/data.rb +74 -0
  11. data/lib/cosmo/job/processor.rb +132 -0
  12. data/lib/cosmo/job.rb +67 -0
  13. data/lib/cosmo/logger.rb +66 -0
  14. data/lib/cosmo/processor.rb +56 -0
  15. data/lib/cosmo/publisher.rb +38 -0
  16. data/lib/cosmo/stream/data.rb +21 -0
  17. data/lib/cosmo/stream/message.rb +31 -0
  18. data/lib/cosmo/stream/processor.rb +94 -0
  19. data/lib/cosmo/stream/serializer.rb +19 -0
  20. data/lib/cosmo/stream.rb +76 -0
  21. data/lib/cosmo/utils/hash.rb +66 -0
  22. data/lib/cosmo/utils/json.rb +23 -0
  23. data/lib/cosmo/utils/signal.rb +24 -0
  24. data/lib/cosmo/utils/stopwatch.rb +32 -0
  25. data/lib/cosmo/utils/string.rb +24 -0
  26. data/lib/cosmo/utils/thread_pool.rb +41 -0
  27. data/lib/cosmo/version.rb +5 -0
  28. data/lib/cosmo.rb +39 -0
  29. data/lib/cosmonats.rb +3 -0
  30. data/sig/cosmo/cli.rbs +25 -0
  31. data/sig/cosmo/client.rbs +30 -0
  32. data/sig/cosmo/config.rbs +48 -0
  33. data/sig/cosmo/engine.rbs +21 -0
  34. data/sig/cosmo/job/data.rbs +35 -0
  35. data/sig/cosmo/job/processor.rbs +23 -0
  36. data/sig/cosmo/job.rbs +35 -0
  37. data/sig/cosmo/logger.rbs +39 -0
  38. data/sig/cosmo/message.rbs +38 -0
  39. data/sig/cosmo/processor.rbs +29 -0
  40. data/sig/cosmo/publisher.rbs +21 -0
  41. data/sig/cosmo/stream/data.rbs +7 -0
  42. data/sig/cosmo/stream/processor.rbs +26 -0
  43. data/sig/cosmo/stream/serializer.rbs +13 -0
  44. data/sig/cosmo/stream.rbs +38 -0
  45. data/sig/cosmo/utils/hash.rbs +25 -0
  46. data/sig/cosmo/utils/json.rbs +13 -0
  47. data/sig/cosmo/utils/signal.rbs +15 -0
  48. data/sig/cosmo/utils/stopwatch.rbs +19 -0
  49. data/sig/cosmo/utils/string.rbs +13 -0
  50. data/sig/cosmo/utils/thread_pool.rbs +18 -0
  51. data/sig/cosmo.rbs +20 -0
  52. 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
@@ -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
@@ -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
@@ -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