cosmonats 0.6.0 → 0.7.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +21 -169
  3. data/README.md +101 -17
  4. data/lib/cosmo/api/counter.rb +14 -11
  5. data/lib/cosmo/api/cron/entry.rb +23 -3
  6. data/lib/cosmo/api/cron.rb +22 -26
  7. data/lib/cosmo/api/job.rb +30 -0
  8. data/lib/cosmo/api/kv.rb +31 -11
  9. data/lib/cosmo/api/registry.rb +68 -0
  10. data/lib/cosmo/api/stats/busy.rb +63 -0
  11. data/lib/cosmo/api/stats/processes.rb +37 -0
  12. data/lib/cosmo/api/stats/totals.rb +35 -0
  13. data/lib/cosmo/api/stats.rb +5 -4
  14. data/lib/cosmo/api/stream.rb +1 -1
  15. data/lib/cosmo/api.rb +3 -2
  16. data/lib/cosmo/batch.rb +9 -2
  17. data/lib/cosmo/cli.rb +27 -7
  18. data/lib/cosmo/client.rb +33 -5
  19. data/lib/cosmo/config.rb +6 -2
  20. data/lib/cosmo/engine.rb +103 -4
  21. data/lib/cosmo/heartbeat.rb +129 -0
  22. data/lib/cosmo/http_server/handler.rb +44 -0
  23. data/lib/cosmo/http_server/health.rb +39 -0
  24. data/lib/cosmo/http_server/ping.rb +23 -0
  25. data/lib/cosmo/http_server.rb +62 -0
  26. data/lib/cosmo/job/failure.rb +52 -0
  27. data/lib/cosmo/job/processor.rb +87 -25
  28. data/lib/cosmo/job/stream_filter.rb +54 -0
  29. data/lib/cosmo/job.rb +2 -0
  30. data/lib/cosmo/processor.rb +39 -4
  31. data/lib/cosmo/sentry/job_processor_middleware.rb +1 -2
  32. data/lib/cosmo/stream/message.rb +2 -1
  33. data/lib/cosmo/stream/processor.rb +5 -0
  34. data/lib/cosmo/utils/json.rb +1 -1
  35. data/lib/cosmo/utils/overrides.rb +16 -10
  36. data/lib/cosmo/utils/thread_pool.rb +5 -0
  37. data/lib/cosmo/version.rb +1 -1
  38. data/lib/cosmo/web/assets/app.css +21 -0
  39. data/lib/cosmo/web/controllers/jobs.rb +28 -4
  40. data/lib/cosmo/web/helpers/application.rb +10 -1
  41. data/lib/cosmo/web/views/batches/index.erb +1 -1
  42. data/lib/cosmo/web/views/jobs/_busy.erb +70 -4
  43. data/lib/cosmo/web/views/jobs/_dead.erb +29 -16
  44. data/lib/cosmo/web/views/jobs/_enqueued.erb +10 -0
  45. data/lib/cosmo/web/views/jobs/_processes.erb +131 -0
  46. data/lib/cosmo/web/views/jobs/_scheduled.erb +1 -1
  47. data/lib/cosmo/web/views/jobs/busy.erb +1 -1
  48. data/lib/cosmo/web/views/jobs/dead.erb +2 -2
  49. data/lib/cosmo/web/views/jobs/enqueued.erb +1 -1
  50. data/lib/cosmo/web/views/jobs/index.erb +7 -1
  51. data/lib/cosmo/web/views/jobs/scheduled.erb +1 -1
  52. data/lib/cosmo/web/views/streams/index.erb +1 -1
  53. data/lib/cosmo/web.rb +2 -0
  54. data/lib/cosmo.rb +13 -0
  55. data/sig/cosmo/api/counter.rbs +2 -4
  56. data/sig/cosmo/api/cron/entry.rbs +2 -0
  57. data/sig/cosmo/api/cron.rbs +4 -2
  58. data/sig/cosmo/api/job.rbs +12 -0
  59. data/sig/cosmo/api/kv.rbs +7 -1
  60. data/sig/cosmo/api/registry.rbs +30 -0
  61. data/sig/cosmo/api/stats/busy.rbs +31 -0
  62. data/sig/cosmo/api/stats/processes.rbs +16 -0
  63. data/sig/cosmo/api/stats/totals.rbs +17 -0
  64. data/sig/cosmo/cli.rbs +4 -0
  65. data/sig/cosmo/client.rbs +12 -1
  66. data/sig/cosmo/engine.rbs +29 -0
  67. data/sig/cosmo/heartbeat.rbs +36 -0
  68. data/sig/cosmo/http_server/handler.rbs +17 -0
  69. data/sig/cosmo/http_server/health.rbs +21 -0
  70. data/sig/cosmo/http_server/ping.rbs +14 -0
  71. data/sig/cosmo/http_server.rbs +22 -0
  72. data/sig/cosmo/job/failure.rbs +28 -0
  73. data/sig/cosmo/job/processor.rbs +17 -1
  74. data/sig/cosmo/job/stream_filter.rbs +25 -0
  75. data/sig/cosmo/processor.rbs +12 -1
  76. data/sig/cosmo/stream/message.rbs +3 -1
  77. data/sig/cosmo/stream/processor.rbs +2 -0
  78. data/sig/cosmo/utils/thread_pool.rbs +3 -0
  79. data/sig/cosmo.rbs +4 -0
  80. metadata +33 -20
  81. data/lib/cosmo/api/busy.rb +0 -66
  82. data/sig/cosmo/api/busy.rbs +0 -35
data/lib/cosmo/api/kv.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  module Cosmo
4
4
  module API
5
5
  class KV
6
+ LIMIT = 25
7
+
6
8
  attr_reader :kv
7
9
 
8
10
  def initialize(name, options = nil)
@@ -39,17 +41,19 @@ module Cosmo
39
41
  kv.delete(key)
40
42
  end
41
43
 
42
- def keys(subject = nil, limit: 25)
43
- results = []
44
- watcher = kv.watch(subject || ">", ignore_deletes: true, meta_only: true)
45
- watcher.each do |entry|
46
- break unless entry
44
+ # Walks the bucket and collects live keys. Pass +limit: nil+ to walk the whole
45
+ # bucket -- a bounded +limit+ stops the watch early, so it can never be used
46
+ # to count (see #count).
47
+ def keys(subject = nil, limit: LIMIT, offset: 0)
48
+ walk(subject, limit:, offset:, meta_only: true).map(&:first)
49
+ end
47
50
 
48
- results << entry.key
49
- break if results.size >= limit
50
- end
51
- watcher.stop
52
- results
51
+ # Same walk as #keys, but carries each value back with its key. The watch
52
+ # already has the values in hand, so callers that need them get one pass
53
+ # instead of a #keys walk followed by a #get per key.
54
+ # @return [Array<Array(String, String)>] +[key, value]+ pairs
55
+ def entries(subject = nil, limit: LIMIT, offset: 0)
56
+ walk(subject, limit:, offset:, meta_only: false)
53
57
  end
54
58
 
55
59
  # Writes a KV-Operation tombstone (same issue as #delete on ttl buckets).
@@ -70,7 +74,7 @@ module Cosmo
70
74
  end
71
75
 
72
76
  def count
73
- keys.size
77
+ keys(limit: nil).size
74
78
  rescue NATS::KeyValue::NoKeysFoundError, NATS::JetStream::Error::NotFound
75
79
  0
76
80
  end
@@ -78,6 +82,22 @@ module Cosmo
78
82
 
79
83
  private
80
84
 
85
+ def walk(subject, limit:, offset:, meta_only:)
86
+ results = []
87
+ skipped = 0
88
+ watcher = kv.watch(subject || ">", ignore_deletes: true, meta_only:)
89
+ watcher.each do |entry|
90
+ break unless entry
91
+
92
+ next skipped += 1 if skipped < offset.to_i
93
+
94
+ results << [entry.key, entry.value]
95
+ break if limit && results.size >= limit
96
+ end
97
+ watcher.stop
98
+ results
99
+ end
100
+
81
101
  # CAS = Compare-And-Swap: publish +value+ with a per-message Nats-TTL,
82
102
  # but only if the subject's current last sequence matches +last_seq+
83
103
  # (sent as the Nats-Expected-Last-Subject-Sequence header). Raises
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module API
5
+ # Base for a KV bucket of JSON entries that live only while their writer keeps refreshing them:
6
+ # a writer re-puts its entries faster than the bucket's TTL, and an entry whose writer is gone expires.
7
+ # Subclasses define +BUCKET+ and +TTL+.
8
+ class Registry
9
+ LIMIT = 25
10
+
11
+ def self.instance
12
+ @instance ||= new
13
+ end
14
+
15
+ def initialize
16
+ @kv = bucket
17
+ end
18
+
19
+ # Writes or refreshes an entry, recreating the bucket if it was deleted under a running writer.
20
+ #
21
+ # @param key [String, Integer]
22
+ # @param value [Hash]
23
+ # @return [void]
24
+ def put(key, value)
25
+ @kv.set(sanitize(key), Utils::Json.dump(value))
26
+ rescue NATS::JetStream::Error::NoStreamResponse
27
+ @kv = bucket
28
+ @kv.set(sanitize(key), Utils::Json.dump(value))
29
+ end
30
+
31
+ # Removes an entry, leaving no tombstone behind.
32
+ #
33
+ # @param key [String, Integer]
34
+ # @return [void]
35
+ def remove(key)
36
+ @kv.erase(sanitize(key))
37
+ end
38
+
39
+ # @param page [Integer, nil]
40
+ # @param limit [Integer]
41
+ # @return [Array<Hash>]
42
+ def list(page: nil, limit: LIMIT)
43
+ offset = ([page.to_i, 1].max - 1) * limit
44
+ @kv.entries(limit:, offset:).filter_map { Utils::Json.parse(_1.last) }
45
+ end
46
+
47
+ # @return [Array<Hash>] every live entry
48
+ def all
49
+ @kv.entries(limit: nil).filter_map { Utils::Json.parse(_1.last) }
50
+ end
51
+
52
+ # @return [Integer] the number of live entries
53
+ def size
54
+ @kv.size
55
+ end
56
+
57
+ private
58
+
59
+ def bucket
60
+ KV.new(self.class::BUCKET, { ttl: self.class::TTL })
61
+ end
62
+
63
+ def sanitize(key)
64
+ key.to_s.gsub(/[^\w.=-]/, "_")
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Cosmo
6
+ module API
7
+ module Stats
8
+ # Jobs being executed right now, one entry per in-flight message, across all workers.
9
+ class Busy < Registry
10
+ TTL = 70
11
+ HEARTBEAT = 30
12
+ BUCKET = "cosmo_jobs_busy"
13
+
14
+ def initialize
15
+ super
16
+ @messages = {}
17
+ end
18
+
19
+ def with(message)
20
+ add(message)
21
+ yield
22
+ ensure
23
+ delete(message)
24
+ end
25
+
26
+ def add(message)
27
+ @thread ||= Thread.new { heartbeat_loop }
28
+ meta = message.metadata
29
+ seq = meta.sequence.stream
30
+ entry = { data: message.data, stream: meta.stream, worker: worker_id,
31
+ started_at: Time.now.to_i, delivery: meta.num_delivered }
32
+ @messages[seq] = entry
33
+ put(seq, entry)
34
+ end
35
+
36
+ def delete(message)
37
+ seq = message.metadata.sequence.stream
38
+ @messages.delete(seq)
39
+ remove(seq)
40
+ end
41
+
42
+ def list(...)
43
+ super.map { _1.merge(data: Utils::Json.parse(_1[:data])) }
44
+ end
45
+
46
+ private
47
+
48
+ def heartbeat_loop
49
+ loop do
50
+ sleep(HEARTBEAT)
51
+ @messages.dup.each { |seq, entry| put(seq, entry) rescue StandardError }
52
+ rescue StandardError => e
53
+ Logger.debug "Busy heartbeat error: #{e.class} #{e.message}"
54
+ end
55
+ end
56
+
57
+ def worker_id
58
+ @worker_id ||= "#{Socket.gethostname}-#{::Process.pid}"
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module API
5
+ module Stats
6
+ # Live worker processes, one entry per process, refreshed by Cosmo::Heartbeat.
7
+ class Processes < Registry
8
+ TTL = 60
9
+ BUCKET = "cosmo_processes"
10
+
11
+ # Sorted by host and pid before paging: every heartbeat rewrites an entry, so the bucket's own
12
+ # order shifts constantly and would move processes between pages.
13
+ #
14
+ # @param page [Integer, nil]
15
+ # @param limit [Integer]
16
+ # @return [Array<Hash>]
17
+ def list(page: nil, limit: LIMIT)
18
+ offset = ([page.to_i, 1].max - 1) * limit
19
+ all.sort_by { [_1[:hostname].to_s, _1[:pid].to_i] }.slice(offset, limit).to_a
20
+ end
21
+
22
+ # @param identity [String] a unique process id, e.g. +hostname-pid+
23
+ # @param info [Hash] the process details shown in the Web UI
24
+ # @return [void]
25
+ def register(identity, info)
26
+ put(identity, info)
27
+ end
28
+
29
+ # @param identity [String]
30
+ # @return [void]
31
+ def unregister(identity)
32
+ remove(identity)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module API
5
+ module Stats
6
+ # Processed and failed job totals across all workers.
7
+ class Totals < Counter
8
+ def self.instance
9
+ @instance ||= new("jobs")
10
+ end
11
+
12
+ # Counts the block's outcome: +true+ is processed, +false+ or a raise is failed.
13
+ #
14
+ # @return [void]
15
+ def with
16
+ result = yield
17
+ increment(:processed) if result == true
18
+ increment(:failed) if result == false
19
+ rescue Exception # rubocop:disable Lint/RescueException
20
+ increment(:failed)
21
+ end
22
+
23
+ # @return [Integer]
24
+ def processed
25
+ get(:processed)
26
+ end
27
+
28
+ # @return [Integer]
29
+ def failed
30
+ get(:failed)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "cosmo/api/counter"
4
- require "cosmo/api/busy"
3
+ require "cosmo/api/stats/totals"
4
+ require "cosmo/api/stats/busy"
5
+ require "cosmo/api/stats/processes"
5
6
 
6
7
  module Cosmo
7
8
  module API
@@ -13,11 +14,11 @@ module Cosmo
13
14
  end
14
15
 
15
16
  def processed
16
- Counter.instance.get(:processed)
17
+ Totals.instance.processed
17
18
  end
18
19
 
19
20
  def failed
20
- Counter.instance.get(:failed)
21
+ Totals.instance.failed
21
22
  end
22
23
 
23
24
  def busy
@@ -36,7 +36,7 @@ module Cosmo
36
36
 
37
37
  def total
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
39
+ cron_count = Client.instance.cron_subjects_in_stream(name, "#{Cron::Entry::SUBJECT_PREFIX}.>").size
40
40
  [all_msgs - cron_count, 0].max
41
41
  rescue NATS::Error
42
42
  0
data/lib/cosmo/api.rb CHANGED
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "cosmo/api/stream"
4
- require "cosmo/api/counter"
5
3
  require "cosmo/api/kv"
4
+ require "cosmo/api/counter"
5
+ require "cosmo/api/registry"
6
+ require "cosmo/api/stream"
6
7
  require "cosmo/api/stats"
7
8
  require "cosmo/api/cron"
8
9
  require "cosmo/api/batch"
data/lib/cosmo/batch.rb CHANGED
@@ -39,6 +39,13 @@ module Cosmo
39
39
  # when everything succeeds. Always call #jobs at least once, even with nothing
40
40
  # in it - a batch that never gets a #jobs call never closes and never fires.
41
41
  #
42
+ # You can pass your own bid instead of letting one be generated - useful
43
+ # when something else (e.g. an ActiveRecord id) needs to match it:
44
+ #
45
+ # Cosmo::Batch.new(bid: your_id)
46
+ #
47
+ # Same meaning as a plain #new either way - it's still a fresh batch.
48
+ #
42
49
  # == How this is actually stored in NATS
43
50
  #
44
51
  # Every batch has an id (bid, a random hex string). State lives
@@ -102,8 +109,8 @@ module Cosmo
102
109
 
103
110
  attr_reader :bid, :parent_id
104
111
 
105
- def initialize(parent: nil)
106
- @bid = SecureRandom.hex(8)
112
+ def initialize(bid: nil, parent: nil)
113
+ @bid = bid || SecureRandom.hex(8)
107
114
  @parent_id = parent
108
115
  kv.set("#{@bid}.meta", Utils::Json.dump({ parent_id: @parent_id, created_at: Time.now.to_i }))
109
116
  link(@parent_id) if @parent_id
data/lib/cosmo/cli.rb CHANGED
@@ -4,7 +4,7 @@ require "yaml"
4
4
  require "optparse"
5
5
 
6
6
  module Cosmo
7
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
7
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
8
8
  class CLI
9
9
  def self.run
10
10
  instance.run
@@ -14,15 +14,22 @@ module Cosmo
14
14
  @instance ||= new
15
15
  end
16
16
 
17
+ # @return [Array<String>, nil] the arguments the process was started with, before parsing consumed them
18
+ attr_reader :argv
19
+
17
20
  def run
21
+ @argv = ARGV.dup
18
22
  flags, command, options = parse
19
23
  return run_setup(flags) if flags[:setup]
20
24
 
21
25
  load_config(flags)
26
+ Engine.processors_for(command).each { _1.validate_options!(options) }
22
27
  puts self.class.banner
23
28
  boot_application
24
29
  require_path(flags[:require])
25
30
  Engine.run(command, options)
31
+ rescue Error => e
32
+ abort e.message
26
33
  end
27
34
 
28
35
  private
@@ -53,6 +60,7 @@ module Cosmo
53
60
  Config.load(path)
54
61
  Config.set(:concurrency, flags[:concurrency]) if flags[:concurrency]
55
62
  Config.set(:timeout, flags[:timeout]) if flags[:timeout]
63
+ Config.set(:http, :port, flags[:http_port]) if flags[:http_port]
56
64
  end
57
65
 
58
66
  def run_setup(flags)
@@ -69,9 +77,11 @@ module Cosmo
69
77
  first_line ? print("Stream is ready: #{name}") : print(", #{name}")
70
78
  first_line = false
71
79
  end
80
+ puts unless first_line
72
81
  end
73
82
 
74
- puts
83
+ API::Counter.setup!
84
+
75
85
  schedules = Config.dig(:setup, :cron)&.reduce(0) do |sum, (name, entry)|
76
86
  class_name = entry.delete(:class)
77
87
  API::Cron.instance.upsert!(**entry, name: name, class_name: class_name)
@@ -129,6 +139,10 @@ module Cosmo
129
139
  flags[:timeout] = arg
130
140
  end
131
141
 
142
+ o.on "-p", "--http-port INT", Integer, "Start HTTP server with /health endpoint on port" do |arg|
143
+ flags[:http_port] = arg
144
+ end
145
+
132
146
  o.on "-C", "--config PATH", "Path to config file" do |arg|
133
147
  flags[:config_file] = arg
134
148
  end
@@ -155,8 +169,16 @@ module Cosmo
155
169
  OptionParser.new do |o|
156
170
  o.banner = "Usage: cosmo jobs [options]"
157
171
 
158
- o.on "--stream NAME", "Job's stream" do |arg|
159
- options[:stream] = arg
172
+ o.on "--streams NAMES", "Only subscribe to these job streams (comma-separated), instead of all configured streams" do |arg|
173
+ options[:streams] = Array(options[:streams]) | arg.split(",")
174
+ end
175
+
176
+ o.on "--stream NAME", "Same as --streams, for a single job stream" do |arg|
177
+ options[:streams] = Array(options[:streams]) | [arg]
178
+ end
179
+
180
+ o.on "--[no-]scheduler", "Dispatch scheduled jobs (default: yes)" do |arg|
181
+ options[:scheduler] = arg
160
182
  end
161
183
 
162
184
  o.on "--subject NAME", "Job's subject" do |arg|
@@ -214,7 +236,7 @@ module Cosmo
214
236
  require path
215
237
  end
216
238
 
217
- # rubocop:disable Layout/TrailingWhitespace,Lint/IneffectiveAccessModifier
239
+ # rubocop:disable-next Layout/TrailingWhitespace,Lint/IneffectiveAccessModifier
218
240
  def self.banner
219
241
  <<-TEXT
220
242
  .#%+:
@@ -254,7 +276,5 @@ module Cosmo
254
276
  :.
255
277
  TEXT
256
278
  end
257
- # rubocop:enable Layout/TrailingWhitespace,Lint/IneffectiveAccessModifier
258
279
  end
259
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
260
280
  end
data/lib/cosmo/client.rb CHANGED
@@ -1,21 +1,49 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "socket"
3
4
  require "nats/client"
4
5
  require "cosmo/utils/overrides"
5
6
 
6
7
  module Cosmo
7
8
  class Client
9
+ # Seconds to wait for a JetStream API reply. Override with COSMO_JS_TIMEOUT.
10
+ JS_TIMEOUT = 5
11
+ # Seconds to wait for the TCP connect+handshake. Override with COSMO_CONNECT_TIMEOUT.
12
+ CONNECT_TIMEOUT = 2
13
+
8
14
  def self.instance
9
15
  @instance ||= Client.new
10
16
  end
11
17
 
12
- attr_reader :nc, :js
18
+ # Labels the connection in `nats server report connections` and /connz, so a
19
+ # misbehaving process can be found without cross-referencing host IPs. Derived from
20
+ # the running program, which separates a web process from a `cosmo` worker on the
21
+ # same host. Override with COSMO_CLIENT_NAME.
22
+ def self.default_name
23
+ ENV.fetch("COSMO_CLIENT_NAME") { "cosmo-#{program_name}-#{Socket.gethostname}-#{Process.pid}" }
24
+ end
25
+
26
+ def self.js_timeout
27
+ ENV.fetch("COSMO_JS_TIMEOUT", JS_TIMEOUT).to_i
28
+ end
29
+
30
+ def self.connect_timeout
31
+ ENV.fetch("COSMO_CONNECT_TIMEOUT", CONNECT_TIMEOUT).to_i
32
+ end
33
+
34
+ # $PROGRAM_NAME is "puma 7.2.1 (tcp://...)" under Puma and a path under the CLI.
35
+ def self.program_name
36
+ File.basename($PROGRAM_NAME.to_s.split.first.to_s, ".*").gsub(/[^\w.-]/, "")
37
+ end
38
+
39
+ attr_reader :nc, :js, :name
13
40
 
14
- def initialize(nats_url: ENV.fetch("NATS_URL", "nats://localhost:4222"))
15
- Logger.debug "Connecting to NATS server at #{nats_url}..."
16
- @nc = NATS.connect(nats_url)
41
+ def initialize(nats_url: ENV.fetch("NATS_URL", "nats://localhost:4222"), name: self.class.default_name)
42
+ @name = name
43
+ Logger.debug "Connecting to NATS server at #{nats_url} as #{@name}..."
44
+ @nc = NATS.connect(nats_url, name: @name, connect_timeout: self.class.connect_timeout)
17
45
  Logger.debug "Connection established"
18
- @js = @nc.jetstream
46
+ @js = @nc.jetstream(timeout: self.class.js_timeout)
19
47
  end
20
48
 
21
49
  def publish(subject, payload, **params)
data/lib/cosmo/config.rb CHANGED
@@ -47,10 +47,14 @@ module Cosmo
47
47
  c[:duplicate_window] = to_ns(Utils::Duration.parse(c[:duplicate_window])) if c[:duplicate_window]
48
48
  c[:subjects] = c[:subjects].map { |s| format(s, name: name) } if c[:subjects]
49
49
 
50
- next unless type == :jobs # Every jobs stream supports NATS 2.14 message scheduling.
50
+ # The scheduled stream carries every cron template, so it alone enables NATS message
51
+ # scheduling. A schedule can only target a subject its own stream covers, and NATS
52
+ # rejects `discard: new` on any stream with scheduling enabled - keeping both here
53
+ # leaves the streams that actually run jobs free to choose their own discard policy.
54
+ next unless type == :jobs && name.to_s == Job::StreamFilter::SCHEDULED.to_s
51
55
 
52
56
  c[:allow_msg_schedules] = true
53
- cron_subject = "#{API::Cron::Entry::SUBJECT_PREFIX}.#{name}.>"
57
+ cron_subject = "#{API::Cron::Entry::SUBJECT_PREFIX}.>"
54
58
  c[:subjects] = Array(c[:subjects])
55
59
  c[:subjects] << cron_subject unless c[:subjects].include?(cron_subject)
56
60
  end
data/lib/cosmo/engine.rb CHANGED
@@ -13,38 +13,137 @@ module Cosmo
13
13
  instance.run(...)
14
14
  end
15
15
 
16
+ # Processor classes a +type+ starts: the matching one, or all of them when it names none.
17
+ #
18
+ # @param type [String, Symbol, nil]
19
+ # @return [Array<Class>]
20
+ def self.processors_for(type)
21
+ type && PROCESSORS.key?(type.to_sym) ? [PROCESSORS[type.to_sym]] : PROCESSORS.values
22
+ end
23
+
16
24
  def self.instance
17
25
  @instance ||= new
18
26
  end
19
27
 
28
+ attr_reader :concurrency
29
+
20
30
  def initialize
21
31
  @concurrency = Config.fetch(:concurrency, 1)
22
32
  @pool = Utils::ThreadPool.new(@concurrency)
23
33
  @running = Concurrent::AtomicBoolean.new
34
+ @quiet = Concurrent::AtomicBoolean.new
35
+ @http_server = nil
36
+ @heartbeat = nil
24
37
  end
25
38
 
26
39
  def run(type, options)
27
- handler = Utils::Signal.trap(:INT, :TERM)
40
+ handler = Utils::Signal.trap(:INT, :TERM, :TSTP, :CONT, :USR1, :TTIN)
28
41
  Logger.info "Starting processing, hit Ctrl-C to stop [concurrency=#{@concurrency}]"
29
42
 
30
- processor_classes = type && PROCESSORS.key?(type.to_sym) ? [PROCESSORS[type.to_sym]] : PROCESSORS.values
31
- @processors = processor_classes.map { _1.run(@pool, @running, options) }
43
+ @processors = self.class.processors_for(type).map { _1.run(@pool, @running, options, quiet: @quiet) }
32
44
  if @running.false?
33
45
  Logger.warn "Shutting down... (No processors are running)"
34
46
  return
35
47
  end
36
48
 
37
- signal = handler.wait
49
+ @heartbeat = Heartbeat.new(self, options:).start
50
+ start_http_server
51
+
52
+ signal = handle_shutdown(handler)
38
53
  Logger.info "Shutting down... (#{signal} received)"
39
54
  shutdown
40
55
  end
41
56
 
57
+ def running?
58
+ @running.true?
59
+ end
60
+
61
+ # @return [String] +running+, +quiet+ (no new work fetched) or +stopping+
62
+ def state
63
+ return "stopping" unless running?
64
+
65
+ @quiet.true? ? "quiet" : "running"
66
+ end
67
+
68
+ # @return [Integer] threads processing messages right now, across all processors
69
+ def busy
70
+ Array(@processors).sum(&:busy)
71
+ end
72
+
73
+ # @return [Hash{Symbol => Array<String>}] what each running processor pulls from, keyed by processor type
74
+ def subscriptions
75
+ Array(@processors).to_h { [PROCESSORS.key(_1.class), _1.subscriptions] }
76
+ end
77
+
42
78
  def shutdown
43
79
  @running.make_false
80
+ @heartbeat&.beat
81
+ @http_server&.stop
44
82
  @pool.shutdown
45
83
  Logger.info "Pausing to allow jobs to finish..."
46
84
  @pool.wait_for_termination(Config[:timeout])
85
+ @heartbeat&.stop
47
86
  Logger.info "Bye!"
48
87
  end
88
+
89
+ private
90
+
91
+ # HTTPServer is autoloaded, so it's referenced only after the port is checked
92
+ # to keep its optional dependencies (rack, webrick) unloaded otherwise.
93
+ def start_http_server
94
+ port = Config.dig(:http, :port)
95
+ return unless port
96
+
97
+ host = Config.dig(:http, :host) || HTTPServer::DEFAULT_HOST
98
+ @http_server = HTTPServer.new(port: port, host: host).start
99
+ end
100
+
101
+ def handle_shutdown(handler)
102
+ loop do
103
+ signal = handler.wait
104
+ case signal.to_s
105
+ when "TSTP" then quiet
106
+ when "CONT" then unquiet
107
+ when "USR1" then drain_and_exit(handler)
108
+ when "TTIN" then dump_threads
109
+ else return signal
110
+ end
111
+ end
112
+ end
113
+
114
+ def quiet
115
+ return unless @quiet.make_true
116
+
117
+ Logger.info "Received TSTP, no new jobs will be fetched; finishing in-flight work"
118
+ @heartbeat&.beat
119
+ end
120
+
121
+ def unquiet
122
+ return unless @quiet.make_false
123
+
124
+ Logger.info "Received CONT, resuming normal fetching"
125
+ @heartbeat&.beat
126
+ end
127
+
128
+ def drain_and_exit(handler)
129
+ return unless @quiet.make_true
130
+
131
+ Logger.info "Received USR1, no new jobs will be fetched, exiting when work drains"
132
+ @heartbeat&.beat
133
+ Thread.new do
134
+ @pool.wait_idle
135
+ handler.push(:TERM)
136
+ end
137
+ end
138
+
139
+ def dump_threads
140
+ threads = Thread.list
141
+ Logger.warn "Received TTIN, dumping backtraces of #{threads.size} threads"
142
+ threads.each do |thread|
143
+ backtrace = thread.backtrace || ["<no backtrace available>"]
144
+ tid = (thread.object_id ^ ::Process.pid).to_s(36)
145
+ Logger.warn "Thread tid=#{tid} name=#{thread.name} [#{thread.status}]\n#{backtrace.join("\n")}"
146
+ end
147
+ end
49
148
  end
50
149
  end