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
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,17 +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
- # 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
55
-
56
- c[:allow_msg_schedules] = true
57
- cron_subject = "#{API::Cron::Entry::SUBJECT_PREFIX}.>"
58
- c[:subjects] = Array(c[:subjects])
59
- c[:subjects] << cron_subject unless c[:subjects].include?(cron_subject)
60
86
  end
61
87
  end
62
88
  end
@@ -84,14 +110,113 @@ module Cosmo
84
110
  @internal ||= {}
85
111
  end
86
112
 
87
- def set(...)
88
- 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)
89
133
  end
90
134
 
91
- def load(path = nil)
92
- 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
93
205
 
94
- 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
95
220
  end
96
221
  end
97
222
  end
@@ -26,6 +26,7 @@ module Cosmo
26
26
  loop do
27
27
  sleep(INTERVAL)
28
28
  beat
29
+ flush
29
30
  end
30
31
  end
31
32
  self
@@ -37,6 +38,7 @@ module Cosmo
37
38
  def stop
38
39
  @thread&.kill&.join
39
40
  @thread = nil
41
+ flush
40
42
  processes.unregister(identity)
41
43
  rescue StandardError => e
42
44
  Logger.debug "Heartbeat unregister error: #{e.class} #{e.message}"
@@ -51,6 +53,15 @@ module Cosmo
51
53
  Logger.debug "Heartbeat error: #{e.class} #{e.message}"
52
54
  end
53
55
 
56
+ # Writes the job metrics recorded since the last flush.
57
+ #
58
+ # @return [void]
59
+ def flush
60
+ API::Stats::Metrics.instance.flush if Config.metrics.enabled
61
+ rescue StandardError => e
62
+ Logger.debug "Metrics flush error: #{e.class} #{e.message}"
63
+ end
64
+
54
65
  # @return [String]
55
66
  def identity
56
67
  @identity ||= "#{Socket.gethostname}-#{::Process.pid}"
@@ -36,8 +36,19 @@ module Cosmo
36
36
  @at ? :scheduled : @options[:stream]
37
37
  end
38
38
 
39
- def subject(target: false)
40
- ["jobs", stream(target:).to_s, Utils::String.underscore(@class_name)]
39
+ # @return [String] the subject the job is published to: the custom +subject:+ option, or the one derived
40
+ # from its stream and class
41
+ def subject
42
+ @subject || subject_for(stream)
43
+ end
44
+
45
+ # @return [Hash{String => String, Integer}] dedup id, plus where to dispatch a delayed job once it is due
46
+ def headers
47
+ headers = { NATS::JetStream::Header::MSG_ID => jid }
48
+ return headers unless @at
49
+
50
+ target = stream(target: true)
51
+ headers.merge("X-Execute-At" => @at.to_i, "X-Stream" => target, "X-Subject" => subject_for(target))
41
52
  end
42
53
 
43
54
  def as_json
@@ -49,18 +60,12 @@ module Cosmo
49
60
  Utils::Json.dump(as_json)
50
61
  end
51
62
 
52
- def to_args
53
- headers = { "Nats-Msg-Id" => jid }
54
- if @at
55
- headers.merge!("X-Execute-At" => @at.to_i,
56
- "X-Stream" => stream(target: true),
57
- "X-Subject" => subject(target: true).join("."))
58
- end
59
- [@subject || subject.join("."), to_json, { stream: stream, header: headers }]
60
- end
61
-
62
63
  private
63
64
 
65
+ def subject_for(stream)
66
+ "jobs.#{stream}.#{Utils::String.underscore(@class_name)}"
67
+ end
68
+
64
69
  def validate!
65
70
  raise ArgumentError, "stream is not provided" unless @options[:stream]
66
71
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module Job
5
+ # The one path a job takes onto its stream, shared by {Job::ClassMethods#perform} and the ActiveJob adapter.
6
+ module Enqueuer
7
+ module_function
8
+
9
+ # Publishes the job through {Config#client_middleware}.
10
+ #
11
+ # @param class_name [String]
12
+ # @param args [Array]
13
+ # @param options [Hash] job options, see {Data}
14
+ # @param batch [Batch, nil] the batch the job joins; it is reserved a pending slot before the publish
15
+ # (see Batch#jobs) and released again when the publish fails or a middleware stops it, so the batch never
16
+ # waits on a lost job
17
+ # @return [String, nil] the job's jid, or nil when a client middleware stopped the publish
18
+ # @raise [StreamNotFoundError] when the target stream does not exist
19
+ # @raise [SchedulingDisabledError] for a delayed job while scheduling is turned off
20
+ def enqueue(class_name, args, options, batch: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
21
+ options = options.merge(batch_id: batch.bid) if batch
22
+ data = Data.new(class_name, args, options)
23
+ raise SchedulingDisabledError if data.stream.to_s == Services::SCHEDULED && !Config.scheduled.enabled
24
+
25
+ batch&.register_job!
26
+ begin
27
+ published = pipe(class_name, data)
28
+ rescue StandardError
29
+ batch&.rollback_job!
30
+ raise
31
+ end
32
+ batch&.rollback_job! unless published
33
+ data.jid if published
34
+ end
35
+
36
+ def pipe(class_name, data)
37
+ payload = data.as_json
38
+ published = false
39
+ Config.client_middleware.invoke(class_name, payload, data.stream(target: true)) do
40
+ publish(data, payload)
41
+ published = true
42
+ end
43
+ published
44
+ end
45
+
46
+ def publish(data, payload)
47
+ Client.instance.publish(data.subject, Utils::Json.dump(payload), stream: data.stream, header: data.headers)
48
+ rescue NATS::JetStream::Error::NoStreamResponse
49
+ raise StreamNotFoundError, data.stream.to_s
50
+ end
51
+ end
52
+ end
53
+ end
@@ -13,14 +13,14 @@ module Cosmo
13
13
  # expires it automatically via the per-message Nats-TTL header. Both
14
14
  # paths leave the slot equally empty -- no tombstone, no delete marker.
15
15
  class Limit
16
- BUCKET = "cosmo_jobs_limits"
16
+ BUCKET = "_cosmolimits"
17
17
 
18
18
  def self.instance
19
19
  @instance ||= new
20
20
  end
21
21
 
22
22
  def initialize
23
- @kv = API::KV.new(BUCKET, allow_msg_ttl: true)
23
+ @kv = API::KV.new(BUCKET, allow_msg_ttl: true, replicas: Config.replicas)
24
24
  end
25
25
 
26
26
  # Try to acquire one of the numbered slots for +key+.
@@ -8,8 +8,8 @@ module Cosmo
8
8
  # Headers the scheduler consumes rather than forwards. The +X-+ ones address the dispatch
9
9
  # itself, and NATS sets +Nats-Scheduler+/+Nats-Schedule-Next+ on a message it fires and
10
10
  # rejects a publish that carries them back.
11
- DISPATCH_HEADERS = %w[X-Stream X-Subject X-Execute-At Nats-Expected-Stream
12
- Nats-Scheduler Nats-Schedule-Next].freeze
11
+ DISPATCH_HEADERS = ["X-Stream", "X-Subject", "X-Execute-At", NATS::JetStream::Header::EXPECTED_STREAM,
12
+ NATS::JetStream::Header::SCHEDULER, NATS::JetStream::Header::SCHEDULE_NEXT].freeze
13
13
 
14
14
  # @raise [UnknownJobStreamError] when --streams/--stream or COSMO_JOBS_STREAMS names an unconfigured stream
15
15
  def self.validate_options!(options)
@@ -32,6 +32,7 @@ module Cosmo
32
32
  API::Stats::Busy.instance
33
33
  API::Stats::Totals.instance
34
34
  Limit.instance
35
+ Config.server_middleware
35
36
 
36
37
  jobs_config = Config.dig(:consumers, :jobs)
37
38
  jobs_config&.each do |stream_name, config|
@@ -53,7 +54,7 @@ module Cosmo
53
54
  end
54
55
 
55
56
  def scheduled_config
56
- Config.dig(:consumers, :jobs, StreamFilter::SCHEDULED)
57
+ Services.scheduled_consumer
57
58
  end
58
59
 
59
60
  def schedule_loop # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Metrics/AbcSize
@@ -70,8 +71,7 @@ module Cosmo
70
71
  messages&.each do |message|
71
72
  headers = message.header.except(*DISPATCH_HEADERS)
72
73
  stream, subject, execute_at = message.header.values_at("X-Stream", "X-Subject", "X-Execute-At")
73
- headers["Nats-Expected-Stream"] = stream
74
- scheduler = message.header["Nats-Scheduler"]
74
+ scheduler = message.header[NATS::JetStream::Header::SCHEDULER]
75
75
  if scheduler
76
76
  headers["X-Scheduled-By"] = scheduler
77
77
  headers["X-Enqueued-At"] = message.metadata.timestamp.to_f.to_s
@@ -79,7 +79,7 @@ module Cosmo
79
79
  execute_at = execute_at.to_i
80
80
 
81
81
  if now >= execute_at
82
- client.publish(subject, message.data, header: headers)
82
+ client.publish(subject, message.data, header: headers, stream:)
83
83
  message.ack
84
84
  else
85
85
  message.nak(delay: Config.to_ns(execute_at - now))
@@ -89,6 +89,7 @@ module Cosmo
89
89
  # to escape #each and kill this thread — schedule_loop only runs once per processor,
90
90
  # so an unhandled exception would silently stop all future scheduled-job dispatch.
91
91
  Logger.error e
92
+ Cosmo.handle_error(e, { source: :scheduler, subject: message.subject })
92
93
  message.nak rescue nil
93
94
  end
94
95
 
@@ -96,7 +97,7 @@ module Cosmo
96
97
  end
97
98
  end
98
99
 
99
- def process(messages, _) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
100
+ def process(messages, _) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
100
101
  message = messages.first
101
102
  Logger.debug "received messages #{messages.inspect}"
102
103
  data = Utils::Json.parse(message.data)
@@ -109,40 +110,36 @@ module Cosmo
109
110
  return
110
111
  end
111
112
 
112
- if worker_class.limits_concurrency?
113
- slot = acquire_concurrency_slot(worker_class, message, data)
114
- return if slot == false
115
- end
116
-
117
- duration = worker_class.default_options[:limit]&.dig(:duration)&.to_i
118
-
119
- with_stats(message) do
113
+ begin
120
114
  sw = stopwatch
121
115
  Logger.with(jid: data[:jid])
122
116
  Logger.info "start"
123
117
 
124
118
  instance = build_worker(worker_class, data, message)
125
- perform_job(instance, data: data, message: message, duration: duration)
119
+ Config.server_middleware.invoke(instance, data, message) do
120
+ perform_job(instance, data: data, message: message)
121
+ end
126
122
 
127
123
  message.ack
128
124
  notify_batch(data, success: true)
129
125
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "done" }
130
- true
126
+ rescue Requeue => e
127
+ message.nak(delay: Config.to_ns(e.delay))
128
+ Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "requeue[#{e.delay}s]" }
131
129
  rescue Timeout::Error => e
132
130
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[timeout]" }
133
- dropped = handle_failure(worker_class, message, data, e)
134
- false if dropped
131
+ Cosmo.handle_error(e, error_context(message, data))
132
+ handle_failure(worker_class, message, data, e)
135
133
  rescue StandardError => e
136
134
  Logger.debug e
137
135
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[error]" }
138
- dropped = handle_failure(worker_class, message, data, e)
139
- false if dropped
136
+ Cosmo.handle_error(e, error_context(message, data))
137
+ handle_failure(worker_class, message, data, e)
140
138
  rescue Exception # rubocop:disable Lint/RescueException
141
139
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[exception]" }
142
140
  raise
143
141
  end
144
142
  ensure
145
- Limit.instance.release(slot) if slot
146
143
  Logger.without(:jid)
147
144
  Logger.debug "processed message #{message.inspect}"
148
145
  end
@@ -157,27 +154,6 @@ module Cosmo
157
154
  end
158
155
  end
159
156
 
160
- # Tries to acquire a concurrency slot for the job.
161
- # Returns the slot key (String) on success, or false if all slots are
162
- # taken (a message is NAK'd with a delay of +retry_in+ before returning
163
- def acquire_concurrency_slot(worker_class, message, data)
164
- options = worker_class.concurrency_options
165
- key = worker_class.concurrency_key(data[:args])
166
-
167
- slot = Limit.instance.acquire(key, jid: data[:jid], limit: options[:limit], duration: options[:duration])
168
- return slot if slot
169
-
170
- message.nak(delay: Config.to_ns(options[:retry_in]))
171
- Logger.debug "concurrency limit reached for #{data[:class]}, re-queueing back #{data[:jid]}"
172
- false
173
- rescue NATS::Error => e
174
- # Unexpected KV failure (e.g. transient NATS error). NAK immediately so
175
- # the message is retried rather than stuck in-flight until ack_wait expires.
176
- Logger.error e
177
- message.nak
178
- false
179
- end
180
-
181
157
  def handle_failure(worker_class, message, data, exception) # rubocop:disable Naming/PredicateMethod
182
158
  current_attempt = message.metadata.num_delivered
183
159
  desired_retries = data[:retry].to_i + 1
@@ -231,6 +207,7 @@ module Cosmo
231
207
  delay.is_a?(Numeric) && delay.positive? ? delay : default_retry_delay(current_attempt)
232
208
  rescue StandardError => e
233
209
  Logger.error e
210
+ Cosmo.handle_error(e, { source: :retry_in, class: data[:class], jid: data[:jid] })
234
211
  default_retry_delay(current_attempt)
235
212
  end
236
213
 
@@ -249,16 +226,24 @@ module Cosmo
249
226
 
250
227
  def drop_message(message, data)
251
228
  message.term
252
- Logger.debug "job dropped #{data[:jid]}"
229
+ Logger.debug "job dropped #{data&.dig(:jid)}"
253
230
  end
254
231
 
255
232
  # Logs why a message can't be processed at all and parks it in the DLQ.
256
233
  def reject_message(message, error, data = nil)
257
234
  Logger.error error
235
+ Cosmo.handle_error(error, error_context(message, data, source: :reject))
258
236
  move_message(message, data, error)
259
237
  end
260
238
 
239
+ def error_context(message, data, source: :job)
240
+ meta = message.metadata
241
+ { source:, stream: meta.stream, subject: message.subject, attempt: meta.num_delivered }.merge(Hash(data))
242
+ end
243
+
261
244
  def move_message(message, data = nil, exception = nil)
245
+ return drop_message(message, data) unless Config.dead.enabled
246
+
262
247
  klass = data ? Utils::String.underscore(data[:class]) : "default"
263
248
  headers = { "X-Stream" => message.metadata.stream, "X-Subject" => message.subject }
264
249
  headers.merge!(Failure.headers(exception)) if exception
@@ -276,7 +261,7 @@ module Cosmo
276
261
  def scheduled_by(message)
277
262
  header = message.header or return
278
263
 
279
- header["X-Scheduled-By"] || header["Nats-Scheduler"]
264
+ header["X-Scheduled-By"] || header[NATS::JetStream::Header::SCHEDULER]
280
265
  end
281
266
 
282
267
  # A cron message is enqueued when NATS fires it, not when the scheduler dispatches it on, so
@@ -305,24 +290,13 @@ module Cosmo
305
290
  ENV.fetch("COSMO_JOBS_FETCH_TIMEOUT", 0.1).to_f
306
291
  end
307
292
 
308
- def with_stats(message, &block)
309
- API::Stats::Busy.instance.with(message) do
310
- API::Stats::Totals.instance.with(&block)
311
- end
312
- end
313
-
314
293
  # @param job_instance [Cosmo::Job]
315
294
  # @param data [Hash]
316
295
  # @param message [NATS::Msg]
317
- # @param duration [Float, nil]
318
296
  #
319
297
  # rubocop:disable-next Lint/UnusedMethodArgument
320
- def perform_job(job_instance, data:, message:, duration: nil)
321
- if duration
322
- Timeout.timeout(duration, Timeout::Error, "execution expired after the #{duration}s duration limit") { job_instance.perform(*data[:args]) }
323
- else
324
- job_instance.perform(*data[:args])
325
- end
298
+ def perform_job(job_instance, data:, message:)
299
+ job_instance.perform(*data[:args])
326
300
  end
327
301
  end
328
302
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module Job
5
+ # Raised by a middleware to put the job back on its stream for later: the processor naks it with +delay+ instead
6
+ # of failing it. The redelivery still counts as a delivery attempt toward the job's +retry+ and +max_deliver+.
7
+ # Raise it from a middleware that runs before the built-in stats middleware, or the attempt is counted as failed.
8
+ #
9
+ # raise Cosmo::Job::Requeue.new(30) if maintenance?
10
+ class Requeue < StandardError
11
+ attr_reader :delay
12
+
13
+ # @param delay [Numeric] seconds before the job is delivered again
14
+ def initialize(delay = 0)
15
+ @delay = delay
16
+ super("requeued for #{delay}s")
17
+ end
18
+ end
19
+ end
20
+ end
data/lib/cosmo/job.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "cosmo/job/data"
4
+ require "cosmo/job/enqueuer"
5
+ require "cosmo/job/requeue"
4
6
  require "cosmo/job/failure"
5
7
  require "cosmo/job/stream_filter"
6
8
  require "cosmo/job/limit"
@@ -96,30 +98,14 @@ module Cosmo
96
98
  end
97
99
 
98
100
  def perform(*args, async: true, **options)
99
- batch = Batch.current if async
100
- options[:batch_id] = batch.bid if batch
101
- data = Data.new(name, args, default_options.merge(options))
102
- unless async
103
- payload = Utils::Json.parse(data.to_args[1])
104
- raise ArgumentError, "Cannot parse payload" unless payload
105
-
106
- new.perform(*payload[:args])
107
- return
108
- end
101
+ options = default_options.merge(options)
102
+ return Enqueuer.enqueue(name, args, options, batch: Batch.current) if async
109
103
 
110
- publish(data, batch)
111
- end
104
+ payload = Utils::Json.parse(Data.new(name, args, options).to_json)
105
+ raise ArgumentError, "Cannot parse payload" unless payload
112
106
 
113
- # The batch is reserved a pending slot before we know the publish will
114
- # succeed (must happen in that order -- see Batch#jobs). Roll it back
115
- # if it never actually made it onto the stream, so the batch doesn't
116
- # hang waiting for a completion that will never arrive.
117
- def publish(data, batch)
118
- batch&.register_job!
119
- Publisher.publish_job(data)
120
- rescue StandardError
121
- batch&.rollback_job!
122
- raise
107
+ new.perform(*payload[:args])
108
+ nil
123
109
  end
124
110
 
125
111
  def perform_async(*args)
data/lib/cosmo/logger.rb CHANGED
@@ -61,7 +61,12 @@ module Cosmo
61
61
  class << self
62
62
  extend Forwardable
63
63
 
64
- delegate %i[info error debug warn fatal trace] => :instance
64
+ delegate %i[info error debug warn fatal] => :instance
65
+ end
66
+
67
+ # Dropped when the logger has no TRACE level (a plain +::Logger+), rather than flooding its DEBUG.
68
+ def self.trace(...)
69
+ instance.trace(...) if instance.respond_to?(:trace)
65
70
  end
66
71
 
67
72
  def self.with(...)
@@ -82,10 +87,18 @@ module Cosmo
82
87
  def self.coerce_level(level)
83
88
  level.to_s.downcase == "trace" ? TRACE : ::Logger::Severity.coerce(level)
84
89
  end
85
- private_class_method :coerce_level
86
90
 
91
+ # @param logger [::Logger] any stdlib-compatible logger. Its formatter is left alone, so context such as
92
+ # +jid+ and +elapsed+ is only printed when it uses {SimpleFormatter}.
87
93
  def self.instance=(logger)
88
94
  @instance = logger
89
95
  end
96
+
97
+ # +COSMO_LOG_LEVEL+, when set, wins over +level+.
98
+ #
99
+ # @param level [Symbol, String, Integer] +:trace+, +:debug+, +:info+, +:warn+, +:error+, or +:fatal+
100
+ def self.level=(level)
101
+ instance.level = coerce_level(ENV.fetch("COSMO_LOG_LEVEL", level))
102
+ end
90
103
  end
91
104
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module Middleware
5
+ # Lists the job on the Web UI's Busy page while it runs.
6
+ class Busy
7
+ # @param _job [Cosmo::Job]
8
+ # @param _data [Hash]
9
+ # @param message [NATS::Msg]
10
+ def call(_job, _data, message, &)
11
+ API::Stats::Busy.instance.with(message, &)
12
+ end
13
+ end
14
+ end
15
+ end