cosmonats 0.5.1 → 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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +21 -169
  3. data/README.md +102 -18
  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 +69 -6
  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/logger.rb +21 -3
  31. data/lib/cosmo/processor.rb +46 -16
  32. data/lib/cosmo/sentry/job_processor_middleware.rb +1 -2
  33. data/lib/cosmo/stream/message.rb +2 -1
  34. data/lib/cosmo/stream/processor.rb +5 -0
  35. data/lib/cosmo/utils/json.rb +1 -1
  36. data/lib/cosmo/utils/overrides.rb +199 -8
  37. data/lib/cosmo/utils/thread_pool.rb +5 -0
  38. data/lib/cosmo/version.rb +1 -1
  39. data/lib/cosmo/web/assets/app.css +21 -0
  40. data/lib/cosmo/web/controllers/jobs.rb +28 -4
  41. data/lib/cosmo/web/helpers/application.rb +10 -1
  42. data/lib/cosmo/web/views/batches/index.erb +1 -1
  43. data/lib/cosmo/web/views/jobs/_busy.erb +70 -4
  44. data/lib/cosmo/web/views/jobs/_dead.erb +29 -16
  45. data/lib/cosmo/web/views/jobs/_enqueued.erb +10 -0
  46. data/lib/cosmo/web/views/jobs/_processes.erb +131 -0
  47. data/lib/cosmo/web/views/jobs/_scheduled.erb +1 -1
  48. data/lib/cosmo/web/views/jobs/busy.erb +1 -1
  49. data/lib/cosmo/web/views/jobs/dead.erb +2 -2
  50. data/lib/cosmo/web/views/jobs/enqueued.erb +1 -1
  51. data/lib/cosmo/web/views/jobs/index.erb +7 -1
  52. data/lib/cosmo/web/views/jobs/scheduled.erb +1 -1
  53. data/lib/cosmo/web/views/streams/index.erb +1 -1
  54. data/lib/cosmo/web.rb +2 -0
  55. data/lib/cosmo.rb +13 -0
  56. data/sig/cosmo/api/counter.rbs +2 -4
  57. data/sig/cosmo/api/cron/entry.rbs +2 -0
  58. data/sig/cosmo/api/cron.rbs +4 -2
  59. data/sig/cosmo/api/job.rbs +12 -0
  60. data/sig/cosmo/api/kv.rbs +7 -1
  61. data/sig/cosmo/api/registry.rbs +30 -0
  62. data/sig/cosmo/api/stats/busy.rbs +31 -0
  63. data/sig/cosmo/api/stats/processes.rbs +16 -0
  64. data/sig/cosmo/api/stats/totals.rbs +17 -0
  65. data/sig/cosmo/cli.rbs +4 -0
  66. data/sig/cosmo/client.rbs +17 -2
  67. data/sig/cosmo/engine.rbs +29 -0
  68. data/sig/cosmo/heartbeat.rbs +36 -0
  69. data/sig/cosmo/http_server/handler.rbs +17 -0
  70. data/sig/cosmo/http_server/health.rbs +21 -0
  71. data/sig/cosmo/http_server/ping.rbs +14 -0
  72. data/sig/cosmo/http_server.rbs +22 -0
  73. data/sig/cosmo/job/failure.rbs +28 -0
  74. data/sig/cosmo/job/processor.rbs +17 -1
  75. data/sig/cosmo/job/stream_filter.rbs +25 -0
  76. data/sig/cosmo/logger.rbs +10 -0
  77. data/sig/cosmo/processor.rbs +12 -4
  78. data/sig/cosmo/stream/message.rbs +3 -1
  79. data/sig/cosmo/stream/processor.rbs +2 -0
  80. data/sig/cosmo/utils/thread_pool.rbs +3 -0
  81. data/sig/cosmo.rbs +4 -0
  82. metadata +33 -20
  83. data/lib/cosmo/api/busy.rb +0 -66
  84. 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,29 +1,62 @@
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)
22
50
  js.publish(subject, payload, **params)
23
51
  end
24
52
 
53
+ # Create a pull subscription. Durable with +consumer_name+, and ephemeral without.
54
+ # @param config [Hash] Consumer config. Ephemeral consumers additionally require
55
+ # +:stream+ and +:inactive_threshold+ (seconds the consumer survives without a fetch).
25
56
  def subscribe(subject, consumer_name, config)
26
- js.pull_subscribe(subject, consumer_name, config: config)
57
+ return js.pull_subscribe(subject, consumer_name, config: config) if consumer_name
58
+
59
+ ephemeral_subscribe(subject, config)
27
60
  end
28
61
 
29
62
  def stream_info(name)
@@ -103,6 +136,10 @@ module Cosmo
103
136
  js.consumer_info(stream_name, consumer_name)
104
137
  end
105
138
 
139
+ def delete_consumer(stream_name, consumer_name)
140
+ js.delete_consumer(stream_name, consumer_name)
141
+ end
142
+
106
143
  def get_message(stream_name, **options)
107
144
  js.get_msg(stream_name, **options)
108
145
  end
@@ -133,6 +170,32 @@ module Cosmo
133
170
 
134
171
  private
135
172
 
173
+ # NOTE: nats-pure's #pull_subscribe has no path to a true ephemeral pull consumer.
174
+ # Its rescue branch unconditionally sets `config[:durable_name] = durable` (jetstream.rb).
175
+ def ephemeral_subscribe(subject, config) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
176
+ config = config.dup
177
+ stream = config.delete(:stream) or raise ArgumentError, "stream required for ephemeral consumers"
178
+ raise ArgumentError, "inactive_threshold required for ephemeral consumers" unless config[:inactive_threshold]
179
+
180
+ subject = subject.first if subject.is_a?(Array) && subject.size == 1
181
+ consumer_config = NATS::JetStream::API::ConsumerConfig.new(config)
182
+ if subject.is_a?(Array)
183
+ consumer_config[:filter_subjects] ||= subject
184
+ else
185
+ consumer_config[:filter_subject] ||= subject
186
+ end
187
+
188
+ info = js.add_consumer(stream, consumer_config)
189
+
190
+ sub = nc.subscribe(nc.new_inbox)
191
+ sub.extend(NATS::JetStream.const_get(:PullSubscription))
192
+ sub.jsi = NATS::JetStream.const_get(:JS)::Sub.new(
193
+ js: js, stream: stream, consumer: info.name,
194
+ nms: "#{js.prefix}.CONSUMER.MSG.NEXT.#{stream}.#{info.name}"
195
+ )
196
+ sub
197
+ end
198
+
136
199
  # NOTE: KV manager in nats-pure hardcodes the fields it copies into StreamConfig,
137
200
  # so `allow_msg_ttl` is never forwarded via create_key_value. Send the raw stream-create API request instead.
138
201
  def create_kv_with_msg_ttl(name, **options)
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