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
@@ -5,27 +5,63 @@ require "timeout"
5
5
  module Cosmo
6
6
  module Job
7
7
  class Processor < ::Cosmo::Processor
8
+ # Headers the scheduler consumes rather than forwards. The +X-+ ones address the dispatch
9
+ # itself, and NATS sets +Nats-Scheduler+/+Nats-Schedule-Next+ on a message it fires and
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
13
+
14
+ # @raise [UnknownJobStreamError] when --streams/--stream or COSMO_JOBS_STREAMS names an unconfigured stream
15
+ def self.validate_options!(options)
16
+ StreamFilter.from(options[:streams]).validate!
17
+ end
18
+
19
+ # @return [Array<String>] job streams pulled from, plus +scheduled+ when dispatching scheduled jobs
20
+ def subscriptions
21
+ names = @consumers.map { |(_, config, _)| config[:stream].to_s }
22
+ names << StreamFilter::SCHEDULED.to_s if scheduler? && scheduled_config
23
+ names
24
+ end
25
+
8
26
  private
9
27
 
10
28
  def setup
29
+ filter = StreamFilter.from(@options[:streams]).validate!
30
+
11
31
  # Initialize singletons before starting to process messages
12
- API::Busy.instance
13
- API::Counter.instance
32
+ API::Stats::Busy.instance
33
+ API::Stats::Totals.instance
14
34
  Limit.instance
35
+ Config.server_middleware
15
36
 
16
37
  jobs_config = Config.dig(:consumers, :jobs)
17
38
  jobs_config&.each do |stream_name, config|
18
- next if stream_name == :scheduled # scheduled jobs are handled in schedule_loop
39
+ next if stream_name == StreamFilter::SCHEDULED # scheduled jobs are handled in schedule_loop
40
+ next unless filter.include?(stream_name)
19
41
 
20
42
  @consumers << subscribe(stream_name, config)
21
43
  end
44
+
45
+ log_subscriptions
46
+ end
47
+
48
+ def log_subscriptions
49
+ return if @consumers.empty?
50
+
51
+ names = @consumers.map { |(_, config, _)| config[:consumer] }
52
+ names << consumer_name(StreamFilter::SCHEDULED) if scheduler? && scheduled_config
53
+ Logger.info "subscribed: #{names.join(", ")}#{" (scheduler off)" unless scheduler?}"
54
+ end
55
+
56
+ def scheduled_config
57
+ Services.scheduled_consumer
22
58
  end
23
59
 
24
60
  def schedule_loop # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength, Metrics/AbcSize
25
- config = Config.dig(:consumers, :jobs, :scheduled)
61
+ config = scheduled_config
26
62
  return unless config
27
63
 
28
- subscription, = subscribe(:scheduled, config)
64
+ subscription, = subscribe(StreamFilter::SCHEDULED, config)
29
65
  while running?
30
66
  break unless running?
31
67
 
@@ -33,13 +69,18 @@ module Cosmo
33
69
  timeout = ENV.fetch("COSMO_JOBS_SCHEDULER_FETCH_TIMEOUT", 5).to_f
34
70
  messages = fetch(subscription, batch_size: 100, timeout:)
35
71
  messages&.each do |message|
36
- headers = message.header.except("X-Stream", "X-Subject", "X-Execute-At", "Nats-Expected-Stream")
72
+ headers = message.header.except(*DISPATCH_HEADERS)
37
73
  stream, subject, execute_at = message.header.values_at("X-Stream", "X-Subject", "X-Execute-At")
38
74
  headers["Nats-Expected-Stream"] = stream
75
+ scheduler = message.header["Nats-Scheduler"]
76
+ if scheduler
77
+ headers["X-Scheduled-By"] = scheduler
78
+ headers["X-Enqueued-At"] = message.metadata.timestamp.to_f.to_s
79
+ end
39
80
  execute_at = execute_at.to_i
40
81
 
41
82
  if now >= execute_at
42
- client.publish(subject, message.data, headers: headers)
83
+ client.publish(subject, message.data, header: headers)
43
84
  message.ack
44
85
  else
45
86
  message.nak(delay: Config.to_ns(execute_at - now))
@@ -49,6 +90,7 @@ module Cosmo
49
90
  # to escape #each and kill this thread — schedule_loop only runs once per processor,
50
91
  # so an unhandled exception would silently stop all future scheduled-job dispatch.
51
92
  Logger.error e
93
+ Cosmo.handle_error(e, { source: :scheduler, subject: message.subject })
52
94
  message.nak rescue nil
53
95
  end
54
96
 
@@ -56,58 +98,49 @@ module Cosmo
56
98
  end
57
99
  end
58
100
 
59
- def process(messages, _) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
101
+ def process(messages, _) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
60
102
  message = messages.first
61
103
  Logger.debug "received messages #{messages.inspect}"
62
104
  data = Utils::Json.parse(message.data)
63
- unless data
64
- Logger.error ArgumentError.new("malformed payload")
65
- move_message(message)
66
- return
67
- end
105
+ return reject_message(message, ArgumentError.new("malformed payload")) unless data
68
106
 
69
107
  worker_class = Utils::String.safe_constantize(data[:class])
70
108
  unless worker_class
71
- Logger.error ArgumentError.new("#{data[:class]} class not found")
72
- move_message(message, data)
109
+ reject_message(message, ArgumentError.new("#{data[:class]} class not found"), data)
73
110
  notify_batch(data, success: false)
74
111
  return
75
112
  end
76
113
 
77
- if worker_class.limits_concurrency?
78
- slot = acquire_concurrency_slot(worker_class, message, data)
79
- return if slot == false
80
- end
81
-
82
- duration = worker_class.default_options[:limit]&.dig(:duration)&.to_i
83
-
84
- with_stats(message) do
114
+ begin
85
115
  sw = stopwatch
86
116
  Logger.with(jid: data[:jid])
87
117
  Logger.info "start"
88
118
 
89
119
  instance = build_worker(worker_class, data, message)
90
- perform_job(instance, data: data, message: message, duration: duration)
120
+ Config.server_middleware.invoke(instance, data, message) do
121
+ perform_job(instance, data: data, message: message)
122
+ end
91
123
 
92
124
  message.ack
93
125
  notify_batch(data, success: true)
94
126
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "done" }
95
- true
127
+ rescue Requeue => e
128
+ message.nak(delay: Config.to_ns(e.delay))
129
+ Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "requeue[#{e.delay}s]" }
96
130
  rescue Timeout::Error => e
97
131
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[timeout]" }
98
- dropped = handle_failure(worker_class, message, data, e)
99
- false if dropped
132
+ Cosmo.handle_error(e, error_context(message, data))
133
+ handle_failure(worker_class, message, data, e)
100
134
  rescue StandardError => e
101
135
  Logger.debug e
102
136
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[error]" }
103
- dropped = handle_failure(worker_class, message, data, e)
104
- false if dropped
137
+ Cosmo.handle_error(e, error_context(message, data))
138
+ handle_failure(worker_class, message, data, e)
105
139
  rescue Exception # rubocop:disable Lint/RescueException
106
140
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail[exception]" }
107
141
  raise
108
142
  end
109
143
  ensure
110
- Limit.instance.release(slot) if slot
111
144
  Logger.without(:jid)
112
145
  Logger.debug "processed message #{message.inspect}"
113
146
  end
@@ -115,34 +148,13 @@ module Cosmo
115
148
  def build_worker(worker_class, data, message)
116
149
  worker_class.new.tap do |worker|
117
150
  worker.jid = data[:jid]
118
- worker.enqueued_at = message.metadata.timestamp
151
+ worker.enqueued_at = enqueued_at(message)
119
152
  worker.attempt = message.metadata.num_delivered
120
- worker.scheduled_by = message.header&.dig("Nats-Scheduler")
153
+ worker.scheduled_by = scheduled_by(message)
121
154
  worker.batch_id = data[:batch_id]
122
155
  end
123
156
  end
124
157
 
125
- # Tries to acquire a concurrency slot for the job.
126
- # Returns the slot key (String) on success, or false if all slots are
127
- # taken (a message is NAK'd with a delay of +retry_in+ before returning
128
- def acquire_concurrency_slot(worker_class, message, data)
129
- options = worker_class.concurrency_options
130
- key = worker_class.concurrency_key(data[:args])
131
-
132
- slot = Limit.instance.acquire(key, jid: data[:jid], limit: options[:limit], duration: options[:duration])
133
- return slot if slot
134
-
135
- message.nak(delay: Config.to_ns(options[:retry_in]))
136
- Logger.debug "concurrency limit reached for #{data[:class]}, re-queueing back #{data[:jid]}"
137
- false
138
- rescue NATS::Error => e
139
- # Unexpected KV failure (e.g. transient NATS error). NAK immediately so
140
- # the message is retried rather than stuck in-flight until ack_wait expires.
141
- Logger.error e
142
- message.nak
143
- false
144
- end
145
-
146
158
  def handle_failure(worker_class, message, data, exception) # rubocop:disable Naming/PredicateMethod
147
159
  current_attempt = message.metadata.num_delivered
148
160
  desired_retries = data[:retry].to_i + 1
@@ -154,7 +166,7 @@ module Cosmo
154
166
  end
155
167
 
156
168
  warn_capped(message, data, capped_at) if capped_at
157
- data[:dead] ? move_message(message, data) : drop_message(message, data)
169
+ data[:dead] ? move_message(message, data, exception) : drop_message(message, data)
158
170
  notify_batch(data, success: false)
159
171
  true
160
172
  end
@@ -196,6 +208,7 @@ module Cosmo
196
208
  delay.is_a?(Numeric) && delay.positive? ? delay : default_retry_delay(current_attempt)
197
209
  rescue StandardError => e
198
210
  Logger.error e
211
+ Cosmo.handle_error(e, { source: :retry_in, class: data[:class], jid: data[:jid] })
199
212
  default_retry_delay(current_attempt)
200
213
  end
201
214
 
@@ -207,26 +220,62 @@ module Cosmo
207
220
  config = config.dup
208
221
  config[:batch_size] = 1
209
222
  config[:stream] = stream_name
210
- config[:consumer] = "consumer-#{stream_name}"
223
+ config[:consumer] = consumer_name(stream_name)
211
224
  subscription = client.subscribe(config[:subject], config[:consumer], config.except(:subject, :priority, :stream, :batch_size, :consumer))
212
225
  [subscription, config, nil]
213
226
  end
214
227
 
215
228
  def drop_message(message, data)
216
229
  message.term
217
- Logger.debug "job dropped #{data[:jid]}"
230
+ Logger.debug "job dropped #{data&.dig(:jid)}"
231
+ end
232
+
233
+ # Logs why a message can't be processed at all and parks it in the DLQ.
234
+ def reject_message(message, error, data = nil)
235
+ Logger.error error
236
+ Cosmo.handle_error(error, error_context(message, data, source: :reject))
237
+ move_message(message, data, error)
238
+ end
239
+
240
+ def error_context(message, data, source: :job)
241
+ meta = message.metadata
242
+ { source:, stream: meta.stream, subject: message.subject, attempt: meta.num_delivered }.merge(Hash(data))
218
243
  end
219
244
 
220
- def move_message(message, data = nil)
245
+ def move_message(message, data = nil, exception = nil)
246
+ return drop_message(message, data) unless Config.dead.enabled
247
+
221
248
  klass = data ? Utils::String.underscore(data[:class]) : "default"
222
249
  headers = { "X-Stream" => message.metadata.stream, "X-Subject" => message.subject }
250
+ headers.merge!(Failure.headers(exception)) if exception
223
251
  Client.instance.publish("jobs.dead.#{klass}", message.data, header: headers)
224
252
  message.ack
225
253
  Logger.debug "job moved #{data&.dig(:jid)} to DLQ"
226
254
  end
227
255
 
228
256
  def scheduler?
229
- true
257
+ @options.fetch(:scheduler, true)
258
+ end
259
+
260
+ # +Nats-Scheduler+ on a message NATS fired directly into a job stream, and +X-Scheduled-By+
261
+ # on one the scheduler dispatched onward, which may not carry the reserved header.
262
+ def scheduled_by(message)
263
+ header = message.header or return
264
+
265
+ header["X-Scheduled-By"] || header["Nats-Scheduler"]
266
+ end
267
+
268
+ # A cron message is enqueued when NATS fires it, not when the scheduler dispatches it on, so
269
+ # the firing forwards its own timestamp in +X-Enqueued-At+ to survive the re-publish.
270
+ def enqueued_at(message)
271
+ forwarded = message.header&.dig("X-Enqueued-At")
272
+
273
+ forwarded ? Time.at(forwarded.to_f) : message.metadata.timestamp
274
+ end
275
+
276
+ # Durable and per stream, so every process pulls from the same consumer and shares the work.
277
+ def consumer_name(stream_name)
278
+ "consumer-#{stream_name}"
230
279
  end
231
280
 
232
281
  def consumers
@@ -242,26 +291,14 @@ module Cosmo
242
291
  ENV.fetch("COSMO_JOBS_FETCH_TIMEOUT", 0.1).to_f
243
292
  end
244
293
 
245
- def with_stats(message, &block)
246
- API::Busy.instance.with(message) do
247
- API::Counter.instance.with(&block)
248
- end
249
- end
250
-
251
294
  # @param job_instance [Cosmo::Job]
252
295
  # @param data [Hash]
253
296
  # @param message [NATS::Msg]
254
- # @param duration [Float, nil]
255
297
  #
256
- # rubocop:disable Lint/UnusedMethodArgument
257
- def perform_job(job_instance, data:, message:, duration: nil)
258
- if duration
259
- Timeout.timeout(duration) { job_instance.perform(*data[:args]) }
260
- else
261
- job_instance.perform(*data[:args])
262
- end
298
+ # rubocop:disable-next Lint/UnusedMethodArgument
299
+ def perform_job(job_instance, data:, message:)
300
+ job_instance.perform(*data[:args])
263
301
  end
264
- # rubocop:enable Lint/UnusedMethodArgument
265
302
  end
266
303
  end
267
304
  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
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module Job
5
+ # Which job streams a worker subscribes to: every configured stream, or the subset named by
6
+ # +--streams+/+--stream+, falling back to the +COSMO_JOBS_STREAMS+ environment variable.
7
+ #
8
+ # The scheduled stream is a service stream. Naming it selects nothing, it runs unless the worker
9
+ # was started with +--no-scheduler+.
10
+ class StreamFilter
11
+ SCHEDULED = :scheduled
12
+ ENV_NAME = "COSMO_JOBS_STREAMS"
13
+
14
+ # @param names [Array<String>, nil] stream names from the command line
15
+ # @return [StreamFilter]
16
+ def self.from(names)
17
+ new(names || ENV[ENV_NAME]&.split(","))
18
+ end
19
+
20
+ # @param names [Array<String>, nil]
21
+ def initialize(names)
22
+ @names = Array(names).map(&:strip).reject(&:empty?)
23
+ end
24
+
25
+ # @return [Boolean] whether every configured stream is selected
26
+ def all?
27
+ @names.empty?
28
+ end
29
+
30
+ # @param stream_name [String, Symbol]
31
+ # @return [Boolean] whether the stream is selected
32
+ def include?(stream_name)
33
+ all? || @names.include?(stream_name.to_s)
34
+ end
35
+
36
+ # @return [StreamFilter] self, so it can be chained onto {.from}
37
+ # @raise [UnknownJobStreamError] when a name is not configured
38
+ def validate!
39
+ return self if all?
40
+
41
+ unknown = @names - configured - [SCHEDULED.to_s]
42
+ raise UnknownJobStreamError.new(unknown, configured) if unknown.any?
43
+
44
+ self
45
+ end
46
+
47
+ private
48
+
49
+ def configured
50
+ @configured ||= Config.dig(:consumers, :jobs)&.keys.to_a.map(&:to_s) - [SCHEDULED.to_s]
51
+ end
52
+ end
53
+ end
54
+ end
data/lib/cosmo/job.rb CHANGED
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "cosmo/job/data"
4
+ require "cosmo/job/enqueuer"
5
+ require "cosmo/job/requeue"
6
+ require "cosmo/job/failure"
7
+ require "cosmo/job/stream_filter"
4
8
  require "cosmo/job/limit"
5
9
  require "cosmo/job/processor"
6
10
 
@@ -94,30 +98,14 @@ module Cosmo
94
98
  end
95
99
 
96
100
  def perform(*args, async: true, **options)
97
- batch = Batch.current if async
98
- options[:batch_id] = batch.bid if batch
99
- data = Data.new(name, args, default_options.merge(options))
100
- unless async
101
- payload = Utils::Json.parse(data.to_args[1])
102
- raise ArgumentError, "Cannot parse payload" unless payload
103
-
104
- new.perform(*payload[:args])
105
- return
106
- end
101
+ options = default_options.merge(options)
102
+ return Enqueuer.enqueue(name, args, options, batch: Batch.current) if async
107
103
 
108
- publish(data, batch)
109
- end
104
+ payload = Utils::Json.parse(Data.new(name, args, options).to_json)
105
+ raise ArgumentError, "Cannot parse payload" unless payload
110
106
 
111
- # The batch is reserved a pending slot before we know the publish will
112
- # succeed (must happen in that order -- see Batch#jobs). Roll it back
113
- # if it never actually made it onto the stream, so the batch doesn't
114
- # hang waiting for a completion that will never arrive.
115
- def publish(data, batch)
116
- batch&.register_job!
117
- Publisher.publish_job(data)
118
- rescue StandardError
119
- batch&.rollback_job!
120
- raise
107
+ new.perform(*payload[:args])
108
+ nil
121
109
  end
122
110
 
123
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
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module Middleware
5
+ # An ordered list of middleware classes wrapped around job execution, outermost first.
6
+ #
7
+ # A middleware is any class whose instances respond to +call(job, data, message)+ and yield to run
8
+ # the rest of the chain. Every invocation builds fresh instances, so a middleware may keep per-job
9
+ # state in instance variables. Adding a class that is already in the chain moves it instead.
10
+ #
11
+ # class Timing
12
+ # def initialize(threshold:) = @threshold = threshold
13
+ #
14
+ # def call(job, data, message)
15
+ # yield
16
+ # end
17
+ # end
18
+ #
19
+ # Cosmo.configure do |config|
20
+ # config.server_middleware { |с| с.add Timing, threshold: 5 }
21
+ # end
22
+ class Chain
23
+ include Enumerable
24
+
25
+ Entry = Struct.new(:klass, :args, :kwargs) do
26
+ def build
27
+ klass.new(*args, **kwargs)
28
+ end
29
+ end
30
+
31
+ # @yieldparam chain [Chain]
32
+ def initialize
33
+ @entries = []
34
+ yield self if block_given?
35
+ end
36
+
37
+ # @yieldparam entry [Entry]
38
+ def each(&)
39
+ @entries.each(&)
40
+ end
41
+
42
+ # Appends +klass+, so it runs innermost, closest to the job.
43
+ #
44
+ # @param klass [Class]
45
+ # @return [Chain]
46
+ def add(klass, *args, **kwargs)
47
+ remove(klass)
48
+ @entries << Entry.new(klass, args, kwargs)
49
+ self
50
+ end
51
+
52
+ # Inserts +klass+ first, so it runs outermost.
53
+ #
54
+ # @param klass [Class]
55
+ # @return [Chain]
56
+ def prepend(klass, *args, **kwargs)
57
+ remove(klass)
58
+ @entries.unshift(Entry.new(klass, args, kwargs))
59
+ self
60
+ end
61
+
62
+ # Inserts +klass+ right before +existing+, or first when +existing+ is not in the chain.
63
+ #
64
+ # @param existing [Class]
65
+ # @param klass [Class]
66
+ # @return [Chain]
67
+ def insert_before(existing, klass, *args, **kwargs)
68
+ entry = take(klass, args, kwargs)
69
+ @entries.insert(index(existing) || 0, entry)
70
+ self
71
+ end
72
+
73
+ # Inserts +klass+ right after +existing+, or last when +existing+ is not in the chain.
74
+ #
75
+ # @param existing [Class]
76
+ # @param klass [Class]
77
+ # @return [Chain]
78
+ def insert_after(existing, klass, *args, **kwargs)
79
+ entry = take(klass, args, kwargs)
80
+ position = index(existing)
81
+ @entries.insert(position ? position + 1 : @entries.size, entry)
82
+ self
83
+ end
84
+
85
+ # @param klass [Class]
86
+ # @return [Chain]
87
+ def remove(klass)
88
+ @entries.delete_if { _1.klass == klass }
89
+ self
90
+ end
91
+
92
+ # @param klass [Class]
93
+ # @return [Boolean]
94
+ def exists?(klass)
95
+ @entries.any? { _1.klass == klass }
96
+ end
97
+ alias include? exists?
98
+
99
+ # @return [Boolean]
100
+ def empty?
101
+ @entries.empty?
102
+ end
103
+
104
+ # @return [Chain]
105
+ def clear
106
+ @entries.clear
107
+ self
108
+ end
109
+
110
+ # Runs the block wrapped in every middleware, passing +args+ to each one's +call+.
111
+ #
112
+ # @return [Object] the outermost middleware's return value, normally the block's
113
+ def invoke(*args, &)
114
+ return yield if empty?
115
+
116
+ traverse(map(&:build), 0, args, &)
117
+ end
118
+
119
+ private
120
+
121
+ def traverse(chain, position, args, &block)
122
+ return yield if position >= chain.size
123
+
124
+ chain[position].call(*args) { traverse(chain, position + 1, args, &block) }
125
+ end
126
+
127
+ def index(klass)
128
+ @entries.index { _1.klass == klass }
129
+ end
130
+
131
+ def take(klass, args, kwargs)
132
+ position = index(klass)
133
+ position ? @entries.delete_at(position) : Entry.new(klass, args, kwargs)
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Cosmo
6
+ module Middleware
7
+ # Enforces a job class's +limit:+ option: a concurrency slot per key, requeueing the job while every slot is
8
+ # taken, and the +duration+ timeout. Jobs without +limit:+ pass straight through. It runs first in the chain,
9
+ # so a requeued job never reaches the stats or user middleware.
10
+ class Limit
11
+ # @param job [Cosmo::Job]
12
+ # @param data [Hash]
13
+ # @param _message [NATS::Msg]
14
+ # @raise [Job::Requeue] while every concurrency slot is taken
15
+ def call(job, data, _message, &)
16
+ limit = job.class.default_options[:limit]
17
+ return yield unless limit
18
+
19
+ slot = acquire(job.class, data)
20
+ timeout(limit[:duration], &)
21
+ ensure
22
+ Job::Limit.instance.release(slot) if slot
23
+ end
24
+
25
+ private
26
+
27
+ def acquire(job_class, data)
28
+ options = job_class.concurrency_options
29
+ return unless options
30
+
31
+ key = job_class.concurrency_key(data[:args])
32
+ slot = Job::Limit.instance.acquire(key, jid: data[:jid], limit: options[:limit], duration: options[:duration])
33
+ raise Job::Requeue, options[:retry_in] unless slot
34
+
35
+ slot
36
+ rescue NATS::Error => e
37
+ Logger.error e
38
+ Cosmo.handle_error(e, { source: :limit, class: job_class.name, jid: data[:jid] })
39
+ raise Job::Requeue
40
+ end
41
+
42
+ def timeout(duration, &)
43
+ return yield unless duration
44
+
45
+ seconds = duration.to_i
46
+ Timeout.timeout(seconds, Timeout::Error, "execution expired after the #{seconds}s duration limit", &)
47
+ end
48
+ end
49
+ end
50
+ end