cosmonats 0.7.0 → 0.9.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 (147) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +219 -113
  3. data/lib/cosmo/active_job/adapter.rb +1 -2
  4. data/lib/cosmo/api/counter.rb +17 -17
  5. data/lib/cosmo/api/cron/entry.rb +10 -11
  6. data/lib/cosmo/api/cron.rb +6 -3
  7. data/lib/cosmo/api/job.rb +1 -1
  8. data/lib/cosmo/api/kv.rb +3 -3
  9. data/lib/cosmo/api/registry.rb +1 -1
  10. data/lib/cosmo/api/stats/busy.rb +6 -1
  11. data/lib/cosmo/api/stats/metrics.rb +109 -0
  12. data/lib/cosmo/api/stats/processes.rb +14 -4
  13. data/lib/cosmo/api/stats/totals.rb +4 -12
  14. data/lib/cosmo/api/stats.rb +18 -2
  15. data/lib/cosmo/api/stream.rb +7 -2
  16. data/lib/cosmo/batch/counters.rb +19 -0
  17. data/lib/cosmo/batch.rb +6 -6
  18. data/lib/cosmo/cli.rb +21 -5
  19. data/lib/cosmo/client.rb +37 -14
  20. data/lib/cosmo/config/cosmo.yml +58 -0
  21. data/lib/cosmo/config/settings.rb +20 -0
  22. data/lib/cosmo/config.rb +144 -19
  23. data/lib/cosmo/heartbeat.rb +11 -0
  24. data/lib/cosmo/job/data.rb +17 -12
  25. data/lib/cosmo/job/enqueuer.rb +53 -0
  26. data/lib/cosmo/job/limit.rb +2 -2
  27. data/lib/cosmo/job/processor.rb +32 -58
  28. data/lib/cosmo/job/requeue.rb +20 -0
  29. data/lib/cosmo/job.rb +8 -22
  30. data/lib/cosmo/logger.rb +15 -2
  31. data/lib/cosmo/middleware/busy.rb +15 -0
  32. data/lib/cosmo/middleware/chain.rb +137 -0
  33. data/lib/cosmo/middleware/limit.rb +50 -0
  34. data/lib/cosmo/middleware/metrics.rb +39 -0
  35. data/lib/cosmo/{sentry/job_processor_middleware.rb → middleware/sentry.rb} +26 -30
  36. data/lib/cosmo/middleware/totals.rb +29 -0
  37. data/lib/cosmo/middleware.rb +7 -0
  38. data/lib/cosmo/processor.rb +11 -5
  39. data/lib/cosmo/publisher.rb +1 -13
  40. data/lib/cosmo/railtie.rb +2 -2
  41. data/lib/cosmo/services.rb +69 -0
  42. data/lib/cosmo/stream/processor.rb +7 -1
  43. data/lib/cosmo/utils/hash.rb +7 -8
  44. data/lib/cosmo/utils.rb +0 -1
  45. data/lib/cosmo/version.rb +1 -1
  46. data/lib/cosmo/web/assets/app.css +271 -571
  47. data/lib/cosmo/web/assets/app.js +158 -0
  48. data/lib/cosmo/web/assets/fonts/OFL.txt +92 -0
  49. data/lib/cosmo/web/assets/fonts/geist-mono.woff2 +0 -0
  50. data/lib/cosmo/web/assets/fonts/geist.woff2 +0 -0
  51. data/lib/cosmo/web/assets/logo.svg +10 -0
  52. data/lib/cosmo/web/cache.rb +75 -0
  53. data/lib/cosmo/web/chart.rb +122 -0
  54. data/lib/cosmo/web/circuit.rb +55 -0
  55. data/lib/cosmo/web/context.rb +0 -4
  56. data/lib/cosmo/web/controllers/application.rb +16 -0
  57. data/lib/cosmo/web/controllers/batches.rb +1 -0
  58. data/lib/cosmo/web/controllers/crons.rb +17 -2
  59. data/lib/cosmo/web/controllers/jobs.rb +59 -54
  60. data/lib/cosmo/web/controllers/metrics.rb +49 -0
  61. data/lib/cosmo/web/controllers/overview.rb +70 -0
  62. data/lib/cosmo/web/controllers/processes.rb +33 -0
  63. data/lib/cosmo/web/controllers/streams.rb +26 -28
  64. data/lib/cosmo/web/helpers/application.rb +67 -0
  65. data/lib/cosmo/web/renderer.rb +19 -6
  66. data/lib/cosmo/web/throughput.rb +104 -0
  67. data/lib/cosmo/web/views/batches/_table.erb +42 -43
  68. data/lib/cosmo/web/views/batches/index.erb +3 -10
  69. data/lib/cosmo/web/views/crons/_table.erb +32 -53
  70. data/lib/cosmo/web/views/crons/index.erb +2 -9
  71. data/lib/cosmo/web/views/jobs/_args.erb +5 -0
  72. data/lib/cosmo/web/views/jobs/_busy.erb +25 -121
  73. data/lib/cosmo/web/views/jobs/_dead.erb +42 -82
  74. data/lib/cosmo/web/views/jobs/_enqueued.erb +33 -113
  75. data/lib/cosmo/web/views/jobs/_panel.erb +29 -0
  76. data/lib/cosmo/web/views/jobs/_queue_row.erb +18 -0
  77. data/lib/cosmo/web/views/jobs/_queues.erb +20 -0
  78. data/lib/cosmo/web/views/jobs/_scheduled.erb +20 -54
  79. data/lib/cosmo/web/views/jobs/index.erb +4 -17
  80. data/lib/cosmo/web/views/layout.erb +73 -22
  81. data/lib/cosmo/web/views/metrics/_chart.erb +41 -0
  82. data/lib/cosmo/web/views/metrics/_panel.erb +13 -0
  83. data/lib/cosmo/web/views/metrics/_table.erb +25 -0
  84. data/lib/cosmo/web/views/metrics/index.erb +9 -0
  85. data/lib/cosmo/web/views/overview/_badges.erb +3 -0
  86. data/lib/cosmo/web/views/overview/_kpis.erb +19 -0
  87. data/lib/cosmo/web/views/overview/_running.erb +18 -0
  88. data/lib/cosmo/web/views/overview/_throughput.erb +50 -0
  89. data/lib/cosmo/web/views/overview/_workers.erb +25 -0
  90. data/lib/cosmo/web/views/overview/index.erb +28 -0
  91. data/lib/cosmo/web/views/processes/_table.erb +67 -0
  92. data/lib/cosmo/web/views/processes/index.erb +1 -0
  93. data/lib/cosmo/web/views/shared/_pager.erb +22 -0
  94. data/lib/cosmo/web/views/shared/unavailable.erb +7 -0
  95. data/lib/cosmo/web/views/streams/_info.erb +28 -86
  96. data/lib/cosmo/web/views/streams/_list.erb +7 -0
  97. data/lib/cosmo/web/views/streams/_pause_banner.erb +8 -16
  98. data/lib/cosmo/web/views/streams/_stream.erb +52 -0
  99. data/lib/cosmo/web/views/streams/index.erb +3 -11
  100. data/lib/cosmo/web/views/streams/info.erb +3 -11
  101. data/lib/cosmo/web.rb +67 -33
  102. data/lib/cosmo.rb +38 -0
  103. data/sig/cosmo/api/counter.rbs +2 -3
  104. data/sig/cosmo/api/cron/entry.rbs +2 -1
  105. data/sig/cosmo/api/stats/busy.rbs +3 -1
  106. data/sig/cosmo/api/stats/metrics.rbs +42 -0
  107. data/sig/cosmo/api/stats/processes.rbs +3 -0
  108. data/sig/cosmo/api/stats/totals.rbs +3 -2
  109. data/sig/cosmo/api/stats.rbs +4 -0
  110. data/sig/cosmo/api/stream.rbs +2 -0
  111. data/sig/cosmo/batch/counters.rbs +10 -0
  112. data/sig/cosmo/batch.rbs +3 -4
  113. data/sig/cosmo/cli.rbs +2 -0
  114. data/sig/cosmo/client.rbs +4 -1
  115. data/sig/cosmo/config/settings.rbs +31 -0
  116. data/sig/cosmo/config.rbs +59 -5
  117. data/sig/cosmo/heartbeat.rbs +2 -0
  118. data/sig/cosmo/job/data.rbs +5 -3
  119. data/sig/cosmo/job/enqueuer.rbs +11 -0
  120. data/sig/cosmo/job/processor.rbs +3 -5
  121. data/sig/cosmo/job/requeue.rbs +9 -0
  122. data/sig/cosmo/job.rbs +0 -2
  123. data/sig/cosmo/logger.rbs +6 -0
  124. data/sig/cosmo/middleware/chain.rbs +51 -0
  125. data/sig/cosmo/middleware/sentry.rbs +14 -0
  126. data/sig/cosmo/middleware.rbs +35 -0
  127. data/sig/cosmo/processor.rbs +2 -0
  128. data/sig/cosmo/publisher.rbs +1 -9
  129. data/sig/cosmo/services.rbs +18 -0
  130. data/sig/cosmo/stream/processor.rbs +2 -0
  131. data/sig/cosmo/utils/hash.rbs +2 -3
  132. data/sig/cosmo.rbs +11 -0
  133. metadata +60 -20
  134. data/lib/cosmo/sentry/auto.rb +0 -6
  135. data/lib/cosmo/utils/overrides.rb +0 -206
  136. data/lib/cosmo/utils/warnings.rb +0 -17
  137. data/lib/cosmo/web/controllers/actions.rb +0 -16
  138. data/lib/cosmo/web/views/actions/index.erb +0 -7
  139. data/lib/cosmo/web/views/jobs/_processes.erb +0 -131
  140. data/lib/cosmo/web/views/jobs/_stats.erb +0 -73
  141. data/lib/cosmo/web/views/jobs/_tabs.erb +0 -8
  142. data/lib/cosmo/web/views/jobs/busy.erb +0 -18
  143. data/lib/cosmo/web/views/jobs/dead.erb +0 -18
  144. data/lib/cosmo/web/views/jobs/enqueued.erb +0 -18
  145. data/lib/cosmo/web/views/jobs/scheduled.erb +0 -18
  146. data/lib/cosmo/web/views/streams/_stream_row.erb +0 -42
  147. data/lib/cosmo/web/views/streams/_table.erb +0 -25
@@ -9,7 +9,7 @@ module Cosmo
9
9
  class Busy < Registry
10
10
  TTL = 70
11
11
  HEARTBEAT = 30
12
- BUCKET = "cosmo_jobs_busy"
12
+ BUCKET = "_cosmobusy"
13
13
 
14
14
  def initialize
15
15
  super
@@ -43,6 +43,11 @@ module Cosmo
43
43
  super.map { _1.merge(data: Utils::Json.parse(_1[:data])) }
44
44
  end
45
45
 
46
+ # @return [Hash{String => Integer}] the number of jobs running per stream, for the streams running any
47
+ def by_stream
48
+ all.group_by { _1[:stream] }.transform_values(&:size)
49
+ end
50
+
46
51
  private
47
52
 
48
53
  def heartbeat_loop
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module API
5
+ module Stats
6
+ # Per-day metrics for every job class: runs, failures, and execution and wait time, kept in their own counters
7
+ # stream for +config.metrics.retention+. Workers {#record} executions in memory and the heartbeat {#flush}es them,
8
+ # so a job costs no NATS round trip; a crashed worker loses at most one heartbeat's worth.
9
+ class Metrics < Counter
10
+ STREAM_NAME = "_cosmometrics"
11
+ DESCRIPTION = "Cosmo metrics"
12
+ DAY = 86_400
13
+
14
+ def self.instance
15
+ @instance ||= new
16
+ end
17
+
18
+ # Each day's counters expire +config.metrics.retention+ after their last update.
19
+ #
20
+ # @return [Hash]
21
+ def self.stream_config
22
+ super.merge(max_age: Config.to_ns(Utils::Duration.parse(Config.metrics.retention)))
23
+ end
24
+
25
+ def initialize
26
+ super("jobs")
27
+ @lock = Mutex.new
28
+ @buffer = buffer
29
+ end
30
+
31
+ # Adds one execution to the buffer {#flush} writes.
32
+ #
33
+ # @param job_class [String]
34
+ # @param exec_ms [Numeric, nil] how long a successful execution took
35
+ # @param wait_ms [Numeric, nil] time from enqueue to the job's first delivery
36
+ # @param failed [Boolean]
37
+ # @return [void]
38
+ def record(job_class, exec_ms: nil, wait_ms: nil, failed: false)
39
+ key = [Time.now.utc.strftime("%Y%m%d"), job_class.gsub("::", "-")]
40
+ @lock.synchronize do
41
+ totals = @buffer[key]
42
+ failed ? totals[:failed] += 1 : totals[:count] += 1
43
+ totals[:exec_ms] += exec_ms if exec_ms
44
+ next unless wait_ms
45
+
46
+ totals[:waited] += 1
47
+ totals[:wait_ms] += wait_ms
48
+ end
49
+ end
50
+
51
+ # Writes the buffered executions to the counters and empties the buffer.
52
+ #
53
+ # @return [void]
54
+ def flush
55
+ pending = @lock.synchronize { @buffer.tap { @buffer = buffer } }
56
+ pending.each do |(day, job), totals|
57
+ totals.each { |field, value| increment("#{day}.#{job}.#{field}", by: value.round) if value.positive? }
58
+ end
59
+ end
60
+
61
+ # @param days [Integer] today and the days before it
62
+ # @return [Array<Hash>] per job class +:job+, +:count+, +:failed+, +:exec_ms+ and +:wait_ms+ (averages in
63
+ # milliseconds, nil without data), busiest first
64
+ def summary(days: 1)
65
+ totals = Hash.new { |hash, job| hash[job] = Hash.new(0) }
66
+ read(days).each { |(_, job, field), value| totals[job][field] += value }
67
+ totals.map { |job, sums| row(sums).merge(job: job) }.sort_by { -_1[:count] }
68
+ end
69
+
70
+ # @param job_class [String, nil] nil sums every job class
71
+ # @param days [Integer]
72
+ # @return [Array<Hash>] one +{ date:, count:, failed:, exec_ms:, wait_ms: }+ per day, oldest first
73
+ def daily(job_class = nil, days: 30) # rubocop:disable Metrics/AbcSize
74
+ sums = Hash.new { |hash, day| hash[day] = Hash.new(0) }
75
+ read(days).each { |(day, job, field), value| sums[day][field] += value if job_class.nil? || job == job_class }
76
+ dates(days).map { |day| row(sums[day]).merge(date: day) }
77
+ end
78
+
79
+ private
80
+
81
+ def buffer
82
+ Hash.new { |hash, key| hash[key] = Hash.new(0) }
83
+ end
84
+
85
+ def read(days)
86
+ filters = dates(days).map { "#{STREAM_NAME}.#{@namespace}.#{_1}.>" }
87
+ client.last_messages(STREAM_NAME, filters).map do |subject, data|
88
+ day, job, field = subject.split(".").last(3)
89
+ [[day, job.gsub("-", "::"), field.to_sym], Utils::Json.parse(data, default: {})[:val].to_i]
90
+ end
91
+ end
92
+
93
+ def dates(days)
94
+ now = Time.now.utc
95
+ (days - 1).downto(0).map { (now - (_1 * DAY)).strftime("%Y%m%d") }
96
+ end
97
+
98
+ def row(sums)
99
+ { count: sums[:count], failed: sums[:failed], exec_ms: average(sums[:exec_ms], sums[:count]),
100
+ wait_ms: average(sums[:wait_ms], sums[:waited]) }
101
+ end
102
+
103
+ def average(total, count)
104
+ total.fdiv(count).round(1) if count.positive?
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -6,17 +6,27 @@ module Cosmo
6
6
  # Live worker processes, one entry per process, refreshed by Cosmo::Heartbeat.
7
7
  class Processes < Registry
8
8
  TTL = 60
9
- BUCKET = "cosmo_processes"
9
+ BUCKET = "_cosmoprocesses"
10
+ KINDS = %w[jobs streams jobs+streams none].freeze
10
11
 
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.
12
+ # Which processors a process is pulling for, judged by its non-empty subscriptions.
13
+ #
14
+ # @param process [Hash]
15
+ # @return [String] one of {KINDS}
16
+ def self.kind(process)
17
+ types = Hash(process[:subscriptions]).reject { |_, names| Array(names).empty? }.keys.map(&:to_s).sort
18
+ types.empty? ? "none" : types.join("+")
19
+ end
20
+
21
+ # Sorted by kind, host and pid before paging: every heartbeat rewrites an entry, so the bucket's own
22
+ # order shifts constantly and would move processes between pages, and each kind stays contiguous.
13
23
  #
14
24
  # @param page [Integer, nil]
15
25
  # @param limit [Integer]
16
26
  # @return [Array<Hash>]
17
27
  def list(page: nil, limit: LIMIT)
18
28
  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
29
+ all.sort_by { [KINDS.index(self.class.kind(_1)) || KINDS.size, _1[:hostname].to_s, _1[:pid].to_i] }.slice(offset, limit).to_a
20
30
  end
21
31
 
22
32
  # @param identity [String] a unique process id, e.g. +hostname-pid+
@@ -3,23 +3,15 @@
3
3
  module Cosmo
4
4
  module API
5
5
  module Stats
6
- # Processed and failed job totals across all workers.
6
+ # Processed and failed job totals across all workers, in the +_cosmototals+ counters. They never expire.
7
7
  class Totals < Counter
8
+ STREAM_NAME = "_cosmototals"
9
+ DESCRIPTION = "Cosmo statistics"
10
+
8
11
  def self.instance
9
12
  @instance ||= new("jobs")
10
13
  end
11
14
 
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
15
  # @return [Integer]
24
16
  def processed
25
17
  get(:processed)
@@ -3,6 +3,7 @@
3
3
  require "cosmo/api/stats/totals"
4
4
  require "cosmo/api/stats/busy"
5
5
  require "cosmo/api/stats/processes"
6
+ require "cosmo/api/stats/metrics"
6
7
 
7
8
  module Cosmo
8
9
  module API
@@ -34,11 +35,26 @@ module Cosmo
34
35
  end
35
36
 
36
37
  def scheduled
37
- Stream.new("scheduled").size
38
+ Config.scheduled.enabled ? Stream.new("scheduled").size : 0
38
39
  end
39
40
 
40
41
  def dead
41
- Stream.new("dead").size
42
+ Config.dead.enabled ? Stream.new("dead").size : 0
43
+ end
44
+
45
+ # @return [Array<Hash>] a {.queue} row per job stream
46
+ def queues
47
+ busy = Busy.instance.by_stream
48
+ Stream.jobs.map { queue(_1, busy) }
49
+ end
50
+
51
+ # @param stream [Stream] a job stream
52
+ # @param busy [Hash{String => Integer}] running jobs per stream, as {Busy#by_stream} counts them
53
+ # @return [Hash] the stream's +:name+, waiting messages as +:size+, running jobs as +:busy+, redeliveries as
54
+ # +:retries+, and whether it is +:paused+
55
+ def queue(stream, busy = Busy.instance.by_stream)
56
+ { name: stream.name, size: stream.size, busy: busy.fetch(stream.name, 0), retries: stream.retries,
57
+ paused: stream.paused? }
42
58
  end
43
59
  end
44
60
  end
@@ -44,9 +44,14 @@ module Cosmo
44
44
  alias size total
45
45
 
46
46
  def retries
47
- client.list_consumers(name).sum { _1["num_redelivered"].to_i }
47
+ consumers.sum { _1["num_redelivered"].to_i }
48
+ end
49
+
50
+ # @return [Array<Hash>] the stream's consumers as JetStream reports them, with their config and delivery state
51
+ def consumers
52
+ client.list_consumers(name)
48
53
  rescue NATS::Error
49
- 0
54
+ []
50
55
  end
51
56
 
52
57
  def each
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/api/counter"
4
+
5
+ module Cosmo
6
+ class Batch
7
+ # Every batch's total, pending, and failed counters, in the +_cosmobatches+ stream. They are purged once the batch
8
+ # finishes, and expire +config.batches.expiry+ after their last update otherwise, along with the batch's KV state.
9
+ class Counters < API::Counter
10
+ STREAM_NAME = "_cosmobatches"
11
+ DESCRIPTION = "Cosmo batch progress"
12
+
13
+ # @return [Hash]
14
+ def self.stream_config
15
+ super.merge(max_age: Config.to_ns(Utils::Duration.parse(Config.batches.expiry)))
16
+ end
17
+ end
18
+ end
19
+ end
data/lib/cosmo/batch.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "securerandom"
4
4
  require "cosmo/batch/callback"
5
5
  require "cosmo/batch/dispatcher"
6
+ require "cosmo/batch/counters"
6
7
 
7
8
  module Cosmo
8
9
  # Groups jobs together and runs a callback once they've all finished.
@@ -51,8 +52,8 @@ module Cosmo
51
52
  # Every batch has an id (bid, a random hex string). State lives
52
53
  # in two places, both keyed by that bid:
53
54
  #
54
- # 1. Three atomic counters (via API::Counter, namespace "batch", backed by
55
- # the shared _cosmostats stream):
55
+ # 1. Three atomic counters (via Batch::Counters, namespace "batch", in the
56
+ # _cosmobatches stream, which expires them with the KV state below):
56
57
  #
57
58
  # <bid>.total - how many jobs have ever joined this batch
58
59
  # <bid>.pending - how many of them are still running
@@ -83,8 +84,7 @@ module Cosmo
83
84
  class Batch
84
85
  extend Dispatcher
85
86
 
86
- BUCKET = "cosmo_jobs_batches"
87
- DEFAULT_EXPIRY = "3d"
87
+ BUCKET = "_cosmobatches"
88
88
  EVENTS = %i[success complete].freeze
89
89
 
90
90
  def self.current
@@ -100,11 +100,11 @@ module Cosmo
100
100
  end
101
101
 
102
102
  def self.counter
103
- @counter ||= API::Counter.new("batch")
103
+ @counter ||= Counters.new("batch")
104
104
  end
105
105
 
106
106
  def self.kv
107
- @kv ||= API::KV.new(BUCKET, ttl: Utils::Duration.parse(Config[:batch_expiry] || DEFAULT_EXPIRY))
107
+ @kv ||= API::KV.new(BUCKET, ttl: Utils::Duration.parse(Config.batches.expiry), replicas: Config.replicas)
108
108
  end
109
109
 
110
110
  attr_reader :bid, :parent_id
data/lib/cosmo/cli.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "yaml"
4
4
  require "optparse"
5
+ require "fileutils"
5
6
 
6
7
  module Cosmo
7
8
  # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
@@ -20,6 +21,7 @@ module Cosmo
20
21
  def run
21
22
  @argv = ARGV.dup
22
23
  flags, command, options = parse
24
+ return run_init if flags[:init]
23
25
  return run_setup(flags) if flags[:setup]
24
26
 
25
27
  load_config(flags)
@@ -57,10 +59,19 @@ module Cosmo
57
59
  path = default_path if File.exist?(default_path)
58
60
  end
59
61
 
60
- Config.load(path)
61
- Config.set(:concurrency, flags[:concurrency]) if flags[:concurrency]
62
- Config.set(:timeout, flags[:timeout]) if flags[:timeout]
63
- Config.set(:http, :port, flags[:http_port]) if flags[:http_port]
62
+ overrides = { concurrency: flags[:concurrency], timeout: flags[:timeout] }.compact
63
+ overrides[:http] = { port: flags[:http_port] } if flags[:http_port]
64
+ Config.load(path, overrides:)
65
+ end
66
+
67
+ def run_init
68
+ path = File.expand_path(Config::DEFAULT_PATH)
69
+ raise Error, "#{Config::DEFAULT_PATH} already exists" if File.exist?(path)
70
+
71
+ FileUtils.mkdir_p(File.dirname(path))
72
+ FileUtils.cp(Config::DEFAULTS_FILE, path)
73
+ puts "Created #{Config::DEFAULT_PATH} with Cosmo's defaults"
74
+ exit(0)
64
75
  end
65
76
 
66
77
  def run_setup(flags)
@@ -80,7 +91,8 @@ module Cosmo
80
91
  puts unless first_line
81
92
  end
82
93
 
83
- API::Counter.setup!
94
+ services = Services.setup!
95
+ puts "Service streams are ready: #{services.join(", ")}" if services.any?
84
96
 
85
97
  schedules = Config.dig(:setup, :cron)&.reduce(0) do |sum, (name, entry)|
86
98
  class_name = entry.delete(:class)
@@ -151,6 +163,10 @@ module Cosmo
151
163
  flags[:setup] = true
152
164
  end
153
165
 
166
+ o.on "--init", "Write #{Config::DEFAULT_PATH} with Cosmo's defaults, then exit" do
167
+ flags[:init] = true
168
+ end
169
+
154
170
  o.on_tail "-v", "--version", "Print version" do
155
171
  puts "Cosmo #{VERSION}"
156
172
  exit(0)
data/lib/cosmo/client.rb CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "socket"
4
4
  require "nats/client"
5
- require "cosmo/utils/overrides"
6
5
 
7
6
  module Cosmo
8
7
  class Client
@@ -10,6 +9,9 @@ module Cosmo
10
9
  JS_TIMEOUT = 5
11
10
  # Seconds to wait for the TCP connect+handshake. Override with COSMO_CONNECT_TIMEOUT.
12
11
  CONNECT_TIMEOUT = 2
12
+ # Reconnect attempts before the connection closes for good; -1 never gives up, so a process outlives a NATS
13
+ # outage. nats-pure lets NATS_MAX_RECONNECT_ATTEMPTS override it.
14
+ MAX_RECONNECT_ATTEMPTS = -1
13
15
 
14
16
  def self.instance
15
17
  @instance ||= Client.new
@@ -41,7 +43,8 @@ module Cosmo
41
43
  def initialize(nats_url: ENV.fetch("NATS_URL", "nats://localhost:4222"), name: self.class.default_name)
42
44
  @name = name
43
45
  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)
46
+ @nc = NATS.connect(nats_url, name: @name, connect_timeout: self.class.connect_timeout,
47
+ max_reconnect_attempts: MAX_RECONNECT_ATTEMPTS)
45
48
  Logger.debug "Connection established"
46
49
  @js = @nc.jetstream(timeout: self.class.js_timeout)
47
50
  end
@@ -144,6 +147,31 @@ module Cosmo
144
147
  js.get_msg(stream_name, **options)
145
148
  end
146
149
 
150
+ # The last message on every subject matching +filters+, in one batched direct get (NATS ≥ 2.11). Needs
151
+ # +allow_direct+ on the stream.
152
+ #
153
+ # @param stream_name [String]
154
+ # @param filters [Array<String>] subject filters, wildcards allowed
155
+ # @return [Hash{String => String}] message data by subject
156
+ def last_messages(stream_name, filters) # rubocop:disable Metrics/AbcSize
157
+ inbox = nc.new_inbox
158
+ sub = nc.subscribe(inbox)
159
+ nc.publish("$JS.API.DIRECT.GET.#{stream_name}", Utils::Json.dump({ multi_last: filters, batch: 10_000 }), inbox)
160
+ messages = {}
161
+ loop do
162
+ msg = sub.next_msg(timeout: self.class.js_timeout)
163
+ subject = msg.header&.dig(NATS::JetStream::Header::SUBJECT)
164
+ break unless subject
165
+
166
+ messages[subject] = msg.data
167
+ end
168
+ messages
169
+ rescue NATS::Timeout
170
+ messages
171
+ ensure
172
+ sub&.unsubscribe
173
+ end
174
+
147
175
  def delete_message(name, seq)
148
176
  response = nc.request("$JS.API.STREAM.MSG.DELETE.#{name}", JSON.dump({ seq: seq }))
149
177
  Utils::Json.parse(response.data, symbolize_names: false)
@@ -170,8 +198,9 @@ module Cosmo
170
198
 
171
199
  private
172
200
 
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).
201
+ # NOTE: nats-pure's #pull_subscribe can't create a true ephemeral pull consumer: its rescue branch
202
+ # unconditionally sets `config[:durable_name] = durable` (jetstream.rb). So the consumer is created
203
+ # here, and #pull_subscribe only binds to it by name.
175
204
  def ephemeral_subscribe(subject, config) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
176
205
  config = config.dup
177
206
  stream = config.delete(:stream) or raise ArgumentError, "stream required for ephemeral consumers"
@@ -186,19 +215,12 @@ module Cosmo
186
215
  end
187
216
 
188
217
  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
218
+ js.pull_subscribe(subject, nil, stream: stream, name: info.name)
197
219
  end
198
220
 
199
221
  # NOTE: KV manager in nats-pure hardcodes the fields it copies into StreamConfig,
200
222
  # so `allow_msg_ttl` is never forwarded via create_key_value. Send the raw stream-create API request instead.
201
- def create_kv_with_msg_ttl(name, **options)
223
+ def create_kv_with_msg_ttl(name, replicas: 1, **options)
202
224
  payload = Utils::Json.dump({
203
225
  name: "KV_#{name}",
204
226
  subjects: ["$KV.#{name}.>"],
@@ -206,7 +228,8 @@ module Cosmo
206
228
  allow_direct: true,
207
229
  allow_msg_ttl: true,
208
230
  allow_rollup_hdrs: true,
209
- max_msgs_per_subject: 1
231
+ max_msgs_per_subject: 1,
232
+ num_replicas: replicas
210
233
  }.merge(options))
211
234
  resp = nc.request("$JS.API.STREAM.CREATE.KV_#{name}", payload)
212
235
  result = Utils::Json.parse(resp.data, symbolize_names: false)
@@ -0,0 +1,58 @@
1
+ # Cosmo configuration. Every value below is what Cosmo uses when you leave it out,
2
+ # so you can delete anything you don't want to change. Listing your own job streams under setup.jobs
3
+ # replaces the `default` stream and its consumer below. Scheduled jobs, dead jobs, and stats are
4
+ # managed by Cosmo itself: tune them in Cosmo.configure instead.
5
+
6
+ timeout: 25 # Shutdown timeout in seconds
7
+ concurrency: 1 # Number of worker threads
8
+ max_retries: 3 # Retries for a job class that sets none
9
+
10
+ #http: # Serve GET /health and /ping
11
+ # port: 9090
12
+ # host: 0.0.0.0
13
+
14
+ stream_config: &stream_config
15
+ storage: file # file or memory
16
+ retention: workqueue # workqueue deletes acked messages; limits keeps them until max_age/max_msgs
17
+ duplicate_window: 120 # seconds a Nats-Msg-Id is remembered, so a resent job is stored once
18
+ discard: new # what to drop when the stream is full: new rejects publishes, old evicts the oldest
19
+ allow_direct: true # required by the web UI
20
+ subjects:
21
+ - jobs.%{name}.> # %{name} is the stream name
22
+
23
+ consumer_config: &consumer_config
24
+ ack_policy: explicit # every message is acked on its own
25
+ max_deliver: 30 # deliveries before a message is given up on; keep it above every job class's retry
26
+ max_ack_pending: 20 # unacked messages before the server stops delivering more
27
+ ack_wait: 60 # seconds before an unacked message is redelivered
28
+ subject: jobs.%{name}.> # %{name} is the stream name
29
+
30
+ setup:
31
+ jobs:
32
+ default:
33
+ <<: *stream_config
34
+ description: Default jobs
35
+ # critical:
36
+ # <<: *stream_config
37
+ # description: Critical jobs
38
+ # streams: # Streams for Cosmo::Stream processors
39
+ # events:
40
+ # storage: file
41
+ # retention: limits
42
+ # max_age: 86400
43
+ # subjects:
44
+ # - events.>
45
+ # cron: # Recurring jobs, synced by `cosmo --setup`
46
+ # daily_report:
47
+ # class: ReportJob
48
+ # schedule: "@daily"
49
+ # stream: default
50
+
51
+ consumers:
52
+ jobs:
53
+ default:
54
+ <<: *consumer_config
55
+ priority: 15 # relative polling weight among job streams
56
+ # critical:
57
+ # <<: *consumer_config
58
+ # priority: 50
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ class Config < ::Hash
5
+ # +config.scheduled+ in Cosmo.configure. Turned off, Cosmo neither creates the scheduled stream nor runs the
6
+ # scheduler, and delayed jobs (+perform_in+/+perform_at+) and crons raise {SchedulingDisabledError}.
7
+ Scheduled = Struct.new(:enabled, keyword_init: true)
8
+
9
+ # +config.dead+ in Cosmo.configure: the dead-letter stream's retention (+max_age+ in seconds or a duration such as
10
+ # "7d"). Turned off, a job that gives up is dropped instead of parked.
11
+ Dead = Struct.new(:enabled, :max_age, :max_msgs, :max_bytes, keyword_init: true)
12
+
13
+ # +config.batches+ in Cosmo.configure: how long a batch's tracking data lives (seconds or a duration such as "3d").
14
+ Batches = Struct.new(:expiry, keyword_init: true)
15
+
16
+ # +config.metrics+ in Cosmo.configure: per-day job metrics behind the web UI's Metrics tab, kept for +retention+
17
+ # (seconds or a duration such as "30d"). Turned off, nothing is recorded and the tab is hidden.
18
+ Metrics = Struct.new(:enabled, :retention, keyword_init: true)
19
+ end
20
+ end