cosmonats 0.6.0 → 0.8.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +21 -169
  3. data/README.md +296 -116
  4. data/lib/cosmo/active_job/adapter.rb +1 -2
  5. data/lib/cosmo/api/counter.rb +18 -15
  6. data/lib/cosmo/api/cron/entry.rb +23 -3
  7. data/lib/cosmo/api/cron.rb +25 -26
  8. data/lib/cosmo/api/job.rb +30 -0
  9. data/lib/cosmo/api/kv.rb +31 -11
  10. data/lib/cosmo/api/registry.rb +68 -0
  11. data/lib/cosmo/api/stats/busy.rb +63 -0
  12. data/lib/cosmo/api/stats/metrics.rb +109 -0
  13. data/lib/cosmo/api/stats/processes.rb +47 -0
  14. data/lib/cosmo/api/stats/totals.rb +27 -0
  15. data/lib/cosmo/api/stats.rb +8 -6
  16. data/lib/cosmo/api/stream.rb +1 -1
  17. data/lib/cosmo/api.rb +3 -2
  18. data/lib/cosmo/batch/counters.rb +19 -0
  19. data/lib/cosmo/batch.rb +15 -8
  20. data/lib/cosmo/cli.rb +46 -10
  21. data/lib/cosmo/client.rb +61 -7
  22. data/lib/cosmo/config/cosmo.yml +58 -0
  23. data/lib/cosmo/config/settings.rb +20 -0
  24. data/lib/cosmo/config.rb +144 -15
  25. data/lib/cosmo/engine.rb +103 -4
  26. data/lib/cosmo/heartbeat.rb +140 -0
  27. data/lib/cosmo/http_server/handler.rb +44 -0
  28. data/lib/cosmo/http_server/health.rb +39 -0
  29. data/lib/cosmo/http_server/ping.rb +23 -0
  30. data/lib/cosmo/http_server.rb +62 -0
  31. data/lib/cosmo/job/data.rb +17 -12
  32. data/lib/cosmo/job/enqueuer.rb +53 -0
  33. data/lib/cosmo/job/failure.rb +52 -0
  34. data/lib/cosmo/job/limit.rb +2 -2
  35. data/lib/cosmo/job/processor.rb +110 -73
  36. data/lib/cosmo/job/requeue.rb +20 -0
  37. data/lib/cosmo/job/stream_filter.rb +54 -0
  38. data/lib/cosmo/job.rb +10 -22
  39. data/lib/cosmo/logger.rb +15 -2
  40. data/lib/cosmo/middleware/busy.rb +15 -0
  41. data/lib/cosmo/middleware/chain.rb +137 -0
  42. data/lib/cosmo/middleware/limit.rb +50 -0
  43. data/lib/cosmo/middleware/metrics.rb +39 -0
  44. data/lib/cosmo/middleware/sentry.rb +61 -0
  45. data/lib/cosmo/middleware/totals.rb +29 -0
  46. data/lib/cosmo/middleware.rb +7 -0
  47. data/lib/cosmo/processor.rb +49 -8
  48. data/lib/cosmo/publisher.rb +1 -13
  49. data/lib/cosmo/railtie.rb +2 -2
  50. data/lib/cosmo/services.rb +69 -0
  51. data/lib/cosmo/stream/message.rb +2 -1
  52. data/lib/cosmo/stream/processor.rb +12 -1
  53. data/lib/cosmo/utils/hash.rb +7 -8
  54. data/lib/cosmo/utils/json.rb +1 -1
  55. data/lib/cosmo/utils/overrides.rb +16 -10
  56. data/lib/cosmo/utils/thread_pool.rb +5 -0
  57. data/lib/cosmo/version.rb +1 -1
  58. data/lib/cosmo/web/assets/app.css +79 -0
  59. data/lib/cosmo/web/chart.rb +98 -0
  60. data/lib/cosmo/web/controllers/crons.rb +8 -0
  61. data/lib/cosmo/web/controllers/jobs.rb +43 -5
  62. data/lib/cosmo/web/controllers/metrics.rb +48 -0
  63. data/lib/cosmo/web/helpers/application.rb +20 -1
  64. data/lib/cosmo/web/views/batches/_table.erb +11 -0
  65. data/lib/cosmo/web/views/batches/index.erb +1 -1
  66. data/lib/cosmo/web/views/jobs/_busy.erb +70 -4
  67. data/lib/cosmo/web/views/jobs/_dead.erb +29 -16
  68. data/lib/cosmo/web/views/jobs/_enqueued.erb +10 -0
  69. data/lib/cosmo/web/views/jobs/_processes.erb +145 -0
  70. data/lib/cosmo/web/views/jobs/_scheduled.erb +1 -1
  71. data/lib/cosmo/web/views/jobs/_stats.erb +4 -0
  72. data/lib/cosmo/web/views/jobs/_tabs.erb +6 -0
  73. data/lib/cosmo/web/views/jobs/busy.erb +1 -1
  74. data/lib/cosmo/web/views/jobs/dead.erb +2 -2
  75. data/lib/cosmo/web/views/jobs/enqueued.erb +1 -1
  76. data/lib/cosmo/web/views/jobs/index.erb +7 -1
  77. data/lib/cosmo/web/views/jobs/scheduled.erb +1 -1
  78. data/lib/cosmo/web/views/layout.erb +1 -1
  79. data/lib/cosmo/web/views/metrics/_chart.erb +36 -0
  80. data/lib/cosmo/web/views/metrics/_panel.erb +13 -0
  81. data/lib/cosmo/web/views/metrics/_table.erb +41 -0
  82. data/lib/cosmo/web/views/metrics/index.erb +17 -0
  83. data/lib/cosmo/web/views/streams/index.erb +1 -1
  84. data/lib/cosmo/web.rb +5 -0
  85. data/lib/cosmo.rb +51 -0
  86. data/sig/cosmo/api/counter.rbs +3 -6
  87. data/sig/cosmo/api/cron/entry.rbs +2 -0
  88. data/sig/cosmo/api/cron.rbs +4 -2
  89. data/sig/cosmo/api/job.rbs +12 -0
  90. data/sig/cosmo/api/kv.rbs +7 -1
  91. data/sig/cosmo/api/registry.rbs +30 -0
  92. data/sig/cosmo/api/stats/busy.rbs +31 -0
  93. data/sig/cosmo/api/stats/metrics.rbs +42 -0
  94. data/sig/cosmo/api/stats/processes.rbs +19 -0
  95. data/sig/cosmo/api/stats/totals.rbs +18 -0
  96. data/sig/cosmo/batch/counters.rbs +10 -0
  97. data/sig/cosmo/batch.rbs +3 -4
  98. data/sig/cosmo/cli.rbs +6 -0
  99. data/sig/cosmo/client.rbs +15 -2
  100. data/sig/cosmo/config/settings.rbs +31 -0
  101. data/sig/cosmo/config.rbs +59 -5
  102. data/sig/cosmo/engine.rbs +29 -0
  103. data/sig/cosmo/heartbeat.rbs +38 -0
  104. data/sig/cosmo/http_server/handler.rbs +17 -0
  105. data/sig/cosmo/http_server/health.rbs +21 -0
  106. data/sig/cosmo/http_server/ping.rbs +14 -0
  107. data/sig/cosmo/http_server.rbs +22 -0
  108. data/sig/cosmo/job/data.rbs +5 -3
  109. data/sig/cosmo/job/enqueuer.rbs +11 -0
  110. data/sig/cosmo/job/failure.rbs +28 -0
  111. data/sig/cosmo/job/processor.rbs +18 -4
  112. data/sig/cosmo/job/requeue.rbs +9 -0
  113. data/sig/cosmo/job/stream_filter.rbs +25 -0
  114. data/sig/cosmo/job.rbs +0 -2
  115. data/sig/cosmo/logger.rbs +6 -0
  116. data/sig/cosmo/middleware/chain.rbs +51 -0
  117. data/sig/cosmo/middleware/sentry.rbs +14 -0
  118. data/sig/cosmo/middleware.rbs +35 -0
  119. data/sig/cosmo/processor.rbs +14 -1
  120. data/sig/cosmo/publisher.rbs +1 -9
  121. data/sig/cosmo/services.rbs +18 -0
  122. data/sig/cosmo/stream/message.rbs +3 -1
  123. data/sig/cosmo/stream/processor.rbs +4 -0
  124. data/sig/cosmo/utils/hash.rbs +2 -3
  125. data/sig/cosmo/utils/thread_pool.rbs +3 -0
  126. data/sig/cosmo.rbs +15 -0
  127. metadata +62 -22
  128. data/lib/cosmo/api/busy.rb +0 -66
  129. data/lib/cosmo/sentry/auto.rb +0 -6
  130. data/lib/cosmo/sentry/job_processor_middleware.rb +0 -66
  131. data/sig/cosmo/api/busy.rbs +0 -35
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.
@@ -39,13 +40,20 @@ module Cosmo
39
40
  # when everything succeeds. Always call #jobs at least once, even with nothing
40
41
  # in it - a batch that never gets a #jobs call never closes and never fires.
41
42
  #
43
+ # You can pass your own bid instead of letting one be generated - useful
44
+ # when something else (e.g. an ActiveRecord id) needs to match it:
45
+ #
46
+ # Cosmo::Batch.new(bid: your_id)
47
+ #
48
+ # Same meaning as a plain #new either way - it's still a fresh batch.
49
+ #
42
50
  # == How this is actually stored in NATS
43
51
  #
44
52
  # Every batch has an id (bid, a random hex string). State lives
45
53
  # in two places, both keyed by that bid:
46
54
  #
47
- # 1. Three atomic counters (via API::Counter, namespace "batch", backed by
48
- # 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):
49
57
  #
50
58
  # <bid>.total - how many jobs have ever joined this batch
51
59
  # <bid>.pending - how many of them are still running
@@ -76,8 +84,7 @@ module Cosmo
76
84
  class Batch
77
85
  extend Dispatcher
78
86
 
79
- BUCKET = "cosmo_jobs_batches"
80
- DEFAULT_EXPIRY = "3d"
87
+ BUCKET = "_cosmobatches"
81
88
  EVENTS = %i[success complete].freeze
82
89
 
83
90
  def self.current
@@ -93,17 +100,17 @@ module Cosmo
93
100
  end
94
101
 
95
102
  def self.counter
96
- @counter ||= API::Counter.new("batch")
103
+ @counter ||= Counters.new("batch")
97
104
  end
98
105
 
99
106
  def self.kv
100
- @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)
101
108
  end
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
@@ -2,9 +2,10 @@
2
2
 
3
3
  require "yaml"
4
4
  require "optparse"
5
+ require "fileutils"
5
6
 
6
7
  module Cosmo
7
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
8
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
8
9
  class CLI
9
10
  def self.run
10
11
  instance.run
@@ -14,15 +15,23 @@ module Cosmo
14
15
  @instance ||= new
15
16
  end
16
17
 
18
+ # @return [Array<String>, nil] the arguments the process was started with, before parsing consumed them
19
+ attr_reader :argv
20
+
17
21
  def run
22
+ @argv = ARGV.dup
18
23
  flags, command, options = parse
24
+ return run_init if flags[:init]
19
25
  return run_setup(flags) if flags[:setup]
20
26
 
21
27
  load_config(flags)
28
+ Engine.processors_for(command).each { _1.validate_options!(options) }
22
29
  puts self.class.banner
23
30
  boot_application
24
31
  require_path(flags[:require])
25
32
  Engine.run(command, options)
33
+ rescue Error => e
34
+ abort e.message
26
35
  end
27
36
 
28
37
  private
@@ -50,9 +59,19 @@ module Cosmo
50
59
  path = default_path if File.exist?(default_path)
51
60
  end
52
61
 
53
- Config.load(path)
54
- Config.set(:concurrency, flags[:concurrency]) if flags[:concurrency]
55
- Config.set(:timeout, flags[:timeout]) if flags[:timeout]
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)
56
75
  end
57
76
 
58
77
  def run_setup(flags)
@@ -69,9 +88,12 @@ module Cosmo
69
88
  first_line ? print("Stream is ready: #{name}") : print(", #{name}")
70
89
  first_line = false
71
90
  end
91
+ puts unless first_line
72
92
  end
73
93
 
74
- puts
94
+ services = Services.setup!
95
+ puts "Service streams are ready: #{services.join(", ")}" if services.any?
96
+
75
97
  schedules = Config.dig(:setup, :cron)&.reduce(0) do |sum, (name, entry)|
76
98
  class_name = entry.delete(:class)
77
99
  API::Cron.instance.upsert!(**entry, name: name, class_name: class_name)
@@ -129,6 +151,10 @@ module Cosmo
129
151
  flags[:timeout] = arg
130
152
  end
131
153
 
154
+ o.on "-p", "--http-port INT", Integer, "Start HTTP server with /health endpoint on port" do |arg|
155
+ flags[:http_port] = arg
156
+ end
157
+
132
158
  o.on "-C", "--config PATH", "Path to config file" do |arg|
133
159
  flags[:config_file] = arg
134
160
  end
@@ -137,6 +163,10 @@ module Cosmo
137
163
  flags[:setup] = true
138
164
  end
139
165
 
166
+ o.on "--init", "Write #{Config::DEFAULT_PATH} with Cosmo's defaults, then exit" do
167
+ flags[:init] = true
168
+ end
169
+
140
170
  o.on_tail "-v", "--version", "Print version" do
141
171
  puts "Cosmo #{VERSION}"
142
172
  exit(0)
@@ -155,8 +185,16 @@ module Cosmo
155
185
  OptionParser.new do |o|
156
186
  o.banner = "Usage: cosmo jobs [options]"
157
187
 
158
- o.on "--stream NAME", "Job's stream" do |arg|
159
- options[:stream] = arg
188
+ o.on "--streams NAMES", "Only subscribe to these job streams (comma-separated), instead of all configured streams" do |arg|
189
+ options[:streams] = Array(options[:streams]) | arg.split(",")
190
+ end
191
+
192
+ o.on "--stream NAME", "Same as --streams, for a single job stream" do |arg|
193
+ options[:streams] = Array(options[:streams]) | [arg]
194
+ end
195
+
196
+ o.on "--[no-]scheduler", "Dispatch scheduled jobs (default: yes)" do |arg|
197
+ options[:scheduler] = arg
160
198
  end
161
199
 
162
200
  o.on "--subject NAME", "Job's subject" do |arg|
@@ -214,7 +252,7 @@ module Cosmo
214
252
  require path
215
253
  end
216
254
 
217
- # rubocop:disable Layout/TrailingWhitespace,Lint/IneffectiveAccessModifier
255
+ # rubocop:disable-next Layout/TrailingWhitespace,Lint/IneffectiveAccessModifier
218
256
  def self.banner
219
257
  <<-TEXT
220
258
  .#%+:
@@ -254,7 +292,5 @@ module Cosmo
254
292
  :.
255
293
  TEXT
256
294
  end
257
- # rubocop:enable Layout/TrailingWhitespace,Lint/IneffectiveAccessModifier
258
295
  end
259
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
260
296
  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)
@@ -116,6 +144,31 @@ module Cosmo
116
144
  js.get_msg(stream_name, **options)
117
145
  end
118
146
 
147
+ # The last message on every subject matching +filters+, in one batched direct get (NATS ≥ 2.11). Needs
148
+ # +allow_direct+ on the stream.
149
+ #
150
+ # @param stream_name [String]
151
+ # @param filters [Array<String>] subject filters, wildcards allowed
152
+ # @return [Hash{String => String}] message data by subject
153
+ def last_messages(stream_name, filters) # rubocop:disable Metrics/AbcSize
154
+ inbox = nc.new_inbox
155
+ sub = nc.subscribe(inbox)
156
+ nc.publish("$JS.API.DIRECT.GET.#{stream_name}", Utils::Json.dump({ multi_last: filters, batch: 10_000 }), inbox)
157
+ messages = {}
158
+ loop do
159
+ msg = sub.next_msg(timeout: self.class.js_timeout)
160
+ subject = msg.header&.dig("Nats-Subject")
161
+ break unless subject
162
+
163
+ messages[subject] = msg.data
164
+ end
165
+ messages
166
+ rescue NATS::Timeout
167
+ messages
168
+ ensure
169
+ sub&.unsubscribe
170
+ end
171
+
119
172
  def delete_message(name, seq)
120
173
  response = nc.request("$JS.API.STREAM.MSG.DELETE.#{name}", JSON.dump({ seq: seq }))
121
174
  Utils::Json.parse(response.data, symbolize_names: false)
@@ -170,7 +223,7 @@ module Cosmo
170
223
 
171
224
  # NOTE: KV manager in nats-pure hardcodes the fields it copies into StreamConfig,
172
225
  # so `allow_msg_ttl` is never forwarded via create_key_value. Send the raw stream-create API request instead.
173
- def create_kv_with_msg_ttl(name, **options)
226
+ def create_kv_with_msg_ttl(name, replicas: 1, **options)
174
227
  payload = Utils::Json.dump({
175
228
  name: "KV_#{name}",
176
229
  subjects: ["$KV.#{name}.>"],
@@ -178,7 +231,8 @@ module Cosmo
178
231
  allow_direct: true,
179
232
  allow_msg_ttl: true,
180
233
  allow_rollup_hdrs: true,
181
- max_msgs_per_subject: 1
234
+ max_msgs_per_subject: 1,
235
+ num_replicas: replicas
182
236
  }.merge(options))
183
237
  resp = nc.request("$JS.API.STREAM.CREATE.KV_#{name}", payload)
184
238
  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
data/lib/cosmo/config.rb CHANGED
@@ -2,31 +2,68 @@
2
2
 
3
3
  require "yaml"
4
4
  require "forwardable"
5
+ require "cosmo/config/settings"
5
6
 
6
7
  module Cosmo
7
8
  class Config < ::Hash
8
9
  NANO = 1_000_000_000
9
10
  DEFAULT_PATH = "config/cosmo.yml"
11
+ DEFAULTS_FILE = File.expand_path(DEFAULT_PATH, __dir__)
12
+ SERVICE_STREAMS = %i[scheduled dead].freeze
10
13
 
11
14
  class << self
12
15
  extend Forwardable
13
16
 
14
- delegate %i[[] fetch dig to_h set load] => :instance
17
+ delegate %i[[] fetch dig load server_middleware client_middleware error_handlers replicas scheduled dead batches metrics] => :instance
15
18
  end
16
19
 
17
20
  def self.to_ns(seconds)
18
21
  (seconds.to_f * NANO).to_i
19
22
  end
20
23
 
24
+ def self.read(path)
25
+ Utils::Hash.symbolize_keys!(YAML.load_file(path, aliases: true) || {})
26
+ end
27
+
21
28
  def self.parse_file(path)
22
- YAML.load_file(path, aliases: true).tap { normalize!(_1) }
29
+ read(path).tap { normalize!(_1) }
30
+ end
31
+
32
+ # The built-in {DEFAULTS_FILE} with +user+ deep-merged over it. Job streams are the one list that is not merged:
33
+ # a user who lists any in +setup.jobs+ gets exactly those, and the built-in +default+ stream and consumer go away.
34
+ # Without +setup.jobs+, +consumers.jobs.default+ still tunes the built-in consumer.
35
+ #
36
+ # @param user [Hash] a parsed config file, not yet normalized
37
+ # @return [Hash] the normalized effective config
38
+ # @raise [ConfigError] when +user+ configures something Cosmo.configure owns
39
+ def self.build(user)
40
+ validate!(user)
41
+ defaults = read(DEFAULTS_FILE)
42
+ if user.dig(:setup, :jobs)
43
+ defaults[:setup].delete(:jobs)
44
+ defaults[:consumers].delete(:jobs)
45
+ end
46
+ Utils::Hash.deep_merge(defaults, user).tap { normalize!(_1) }
47
+ end
48
+
49
+ def self.validate!(config)
50
+ %i[setup consumers].each do |section|
51
+ jobs = config.dig(section, :jobs)
52
+ name = SERVICE_STREAMS.find { jobs.is_a?(::Hash) && jobs.key?(_1) }
53
+ next unless name
54
+
55
+ raise ConfigError, "`#{section}.jobs.#{name}` is a Cosmo service stream: remove it from cosmo.yml " \
56
+ "and tune it with Cosmo.configure { |config| config.#{name} }"
57
+ end
58
+ return unless config.key?(:batch_expiry)
59
+
60
+ raise ConfigError, "`batch_expiry` moved out of cosmo.yml: Cosmo.configure { |config| config.batches.expiry = ... }"
23
61
  end
24
62
 
25
63
  def self.normalize!(config) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
26
64
  Utils::Hash.symbolize_keys!(config)
27
65
 
28
66
  config[:timeout] = Utils::Duration.parse(config[:timeout]) if config[:timeout]
29
- config[:batch_expiry] = Utils::Duration.parse(config[:batch_expiry]) if config[:batch_expiry]
30
67
 
31
68
  config[:consumers]&.each_key do |name|
32
69
  config[:consumers][name].each do |stream_name, c|
@@ -46,13 +83,6 @@ module Cosmo
46
83
  c[:max_age] = to_ns(Utils::Duration.parse(c[:max_age])) if c[:max_age]
47
84
  c[:duplicate_window] = to_ns(Utils::Duration.parse(c[:duplicate_window])) if c[:duplicate_window]
48
85
  c[:subjects] = c[:subjects].map { |s| format(s, name: name) } if c[:subjects]
49
-
50
- next unless type == :jobs # Every jobs stream supports NATS 2.14 message scheduling.
51
-
52
- c[:allow_msg_schedules] = true
53
- cron_subject = "#{API::Cron::Entry::SUBJECT_PREFIX}.#{name}.>"
54
- c[:subjects] = Array(c[:subjects])
55
- c[:subjects] << cron_subject unless c[:subjects].include?(cron_subject)
56
86
  end
57
87
  end
58
88
  end
@@ -80,14 +110,113 @@ module Cosmo
80
110
  @internal ||= {}
81
111
  end
82
112
 
83
- def set(...)
84
- Utils::Hash.set(self, ...)
113
+ # Replaces the contents with {.build} of the file at +path+ (or of no file), with +overrides+ merged over it, so
114
+ # command-line flags win over the file.
115
+ #
116
+ # @param path [String, nil]
117
+ # @param overrides [Hash, nil] e.g. +{ concurrency: 5 }+
118
+ def load(path = nil, overrides: nil)
119
+ user = path ? self.class.read(path) : {}
120
+ replace(self.class.build(Utils::Hash.deep_merge(user, Hash(overrides))))
121
+ end
122
+
123
+ attr_writer :replicas
124
+
125
+ # @return [Integer] replicas for every service stream and bucket, e.g. 3 on a NATS cluster
126
+ def replicas
127
+ @replicas || 1
128
+ end
129
+
130
+ # @return [Scheduled]
131
+ def scheduled
132
+ @scheduled ||= Scheduled.new(enabled: true)
85
133
  end
86
134
 
87
- def load(path = nil)
88
- return unless path
135
+ # @return [Dead]
136
+ def dead
137
+ @dead ||= Dead.new(enabled: true, max_age: 7 * 86_400, max_msgs: 10_000, max_bytes: -1)
138
+ end
139
+
140
+ # @return [Batches]
141
+ def batches
142
+ @batches ||= Batches.new(expiry: 3 * 86_400)
143
+ end
144
+
145
+ # @return [Metrics]
146
+ def metrics
147
+ @metrics ||= Metrics.new(enabled: true, retention: "30d")
148
+ end
149
+
150
+ # Callables given every error Cosmo rescues: failed jobs and stream batches, fetch and scheduler errors, rejected
151
+ # messages. Each is called with +(error, context)+, +context+ being a Hash with a +:source+ (+:job+, +:stream+,
152
+ # +:fetch+, +:scheduler+, +:reject+, +:retry_in+, +:limit+) and what is known there, e.g. a job's payload.
153
+ #
154
+ # Cosmo.configure do |config|
155
+ # config.error_handlers << ->(error, context) { Honeybadger.notify(error, context: context) }
156
+ # end
157
+ #
158
+ # @return [Array<#call>]
159
+ def error_handlers
160
+ @error_handlers ||= []
161
+ end
162
+
163
+ # @return [::Logger] the logger Cosmo writes to, see {Logger.instance}
164
+ def logger
165
+ Logger.instance
166
+ end
167
+
168
+ # Replaces Cosmo's stdout logger, e.g. with +Rails.logger+. Give it +Cosmo::Logger::SimpleFormatter.new+ to keep
169
+ # Cosmo's +jid+/+elapsed+ context in each line.
170
+ #
171
+ # @param logger [::Logger]
172
+ def logger=(logger)
173
+ Logger.instance = logger
174
+ end
175
+
176
+ # Ignored when +COSMO_LOG_LEVEL+ is set, which always wins.
177
+ #
178
+ # @param level [Symbol, String, Integer] +:trace+, +:debug+, +:info+, +:warn+, +:error+, or +:fatal+
179
+ def log_level=(level)
180
+ Logger.level = level
181
+ end
182
+
183
+ # The chain every job execution runs through, starting with the built-in {Middleware::Limit}, {Middleware::Busy},
184
+ # {Middleware::Totals}, and {Middleware::Metrics}. It lives outside the loaded YAML, so {#load} keeps it. Register middleware
185
+ # at boot, before workers start:
186
+ #
187
+ # Cosmo.configure do |config|
188
+ # config.server_middleware do |chain|
189
+ # chain.add MyMiddleware
190
+ # end
191
+ # end
192
+ #
193
+ # @yieldparam chain [Middleware::Chain]
194
+ # @return [Middleware::Chain]
195
+ def server_middleware
196
+ @server_middleware ||= Middleware::Chain.new do |chain|
197
+ chain.add Middleware::Limit
198
+ chain.add Middleware::Busy
199
+ chain.add Middleware::Totals
200
+ chain.add Middleware::Metrics
201
+ end
202
+ yield @server_middleware if block_given?
203
+ @server_middleware
204
+ end
89
205
 
90
- replace(self.class.parse_file(path))
206
+ # The chain every job enqueue runs through, empty by default. A middleware is called with
207
+ # +(job_class_name, payload, stream)+ around the publish: it may change +payload+ (the Hash that gets published),
208
+ # or not yield to stop the publish, so +perform_async+ returns +nil+. +stream+ is the stream the job runs on.
209
+ #
210
+ # Cosmo.configure do |config|
211
+ # config.client_middleware { |chain| chain.add RequestIdMiddleware }
212
+ # end
213
+ #
214
+ # @yieldparam chain [Middleware::Chain]
215
+ # @return [Middleware::Chain]
216
+ def client_middleware
217
+ @client_middleware ||= Middleware::Chain.new
218
+ yield @client_middleware if block_given?
219
+ @client_middleware
91
220
  end
92
221
  end
93
222
  end