cosmonats 0.7.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +199 -103
  3. data/lib/cosmo/active_job/adapter.rb +1 -2
  4. data/lib/cosmo/api/counter.rb +16 -16
  5. data/lib/cosmo/api/cron.rb +3 -0
  6. data/lib/cosmo/api/registry.rb +1 -1
  7. data/lib/cosmo/api/stats/busy.rb +1 -1
  8. data/lib/cosmo/api/stats/metrics.rb +109 -0
  9. data/lib/cosmo/api/stats/processes.rb +14 -4
  10. data/lib/cosmo/api/stats/totals.rb +4 -12
  11. data/lib/cosmo/api/stats.rb +3 -2
  12. data/lib/cosmo/batch/counters.rb +19 -0
  13. data/lib/cosmo/batch.rb +6 -6
  14. data/lib/cosmo/cli.rb +21 -5
  15. data/lib/cosmo/client.rb +28 -2
  16. data/lib/cosmo/config/cosmo.yml +58 -0
  17. data/lib/cosmo/config/settings.rb +20 -0
  18. data/lib/cosmo/config.rb +144 -19
  19. data/lib/cosmo/heartbeat.rb +11 -0
  20. data/lib/cosmo/job/data.rb +17 -12
  21. data/lib/cosmo/job/enqueuer.rb +53 -0
  22. data/lib/cosmo/job/limit.rb +2 -2
  23. data/lib/cosmo/job/processor.rb +27 -52
  24. data/lib/cosmo/job/requeue.rb +20 -0
  25. data/lib/cosmo/job.rb +8 -22
  26. data/lib/cosmo/logger.rb +15 -2
  27. data/lib/cosmo/middleware/busy.rb +15 -0
  28. data/lib/cosmo/middleware/chain.rb +137 -0
  29. data/lib/cosmo/middleware/limit.rb +50 -0
  30. data/lib/cosmo/middleware/metrics.rb +39 -0
  31. data/lib/cosmo/{sentry/job_processor_middleware.rb → middleware/sentry.rb} +26 -30
  32. data/lib/cosmo/middleware/totals.rb +29 -0
  33. data/lib/cosmo/middleware.rb +7 -0
  34. data/lib/cosmo/processor.rb +11 -5
  35. data/lib/cosmo/publisher.rb +1 -13
  36. data/lib/cosmo/railtie.rb +2 -2
  37. data/lib/cosmo/services.rb +69 -0
  38. data/lib/cosmo/stream/processor.rb +7 -1
  39. data/lib/cosmo/utils/hash.rb +7 -8
  40. data/lib/cosmo/version.rb +1 -1
  41. data/lib/cosmo/web/assets/app.css +58 -0
  42. data/lib/cosmo/web/chart.rb +98 -0
  43. data/lib/cosmo/web/controllers/crons.rb +8 -0
  44. data/lib/cosmo/web/controllers/jobs.rb +16 -2
  45. data/lib/cosmo/web/controllers/metrics.rb +48 -0
  46. data/lib/cosmo/web/helpers/application.rb +10 -0
  47. data/lib/cosmo/web/views/batches/_table.erb +11 -0
  48. data/lib/cosmo/web/views/jobs/_processes.erb +49 -35
  49. data/lib/cosmo/web/views/jobs/_stats.erb +4 -0
  50. data/lib/cosmo/web/views/jobs/_tabs.erb +6 -0
  51. data/lib/cosmo/web/views/jobs/index.erb +1 -1
  52. data/lib/cosmo/web/views/layout.erb +1 -1
  53. data/lib/cosmo/web/views/metrics/_chart.erb +36 -0
  54. data/lib/cosmo/web/views/metrics/_panel.erb +13 -0
  55. data/lib/cosmo/web/views/metrics/_table.erb +41 -0
  56. data/lib/cosmo/web/views/metrics/index.erb +17 -0
  57. data/lib/cosmo/web.rb +3 -0
  58. data/lib/cosmo.rb +38 -0
  59. data/sig/cosmo/api/counter.rbs +2 -3
  60. data/sig/cosmo/api/stats/busy.rbs +1 -1
  61. data/sig/cosmo/api/stats/metrics.rbs +42 -0
  62. data/sig/cosmo/api/stats/processes.rbs +3 -0
  63. data/sig/cosmo/api/stats/totals.rbs +3 -2
  64. data/sig/cosmo/batch/counters.rbs +10 -0
  65. data/sig/cosmo/batch.rbs +3 -4
  66. data/sig/cosmo/cli.rbs +2 -0
  67. data/sig/cosmo/client.rbs +3 -1
  68. data/sig/cosmo/config/settings.rbs +31 -0
  69. data/sig/cosmo/config.rbs +59 -5
  70. data/sig/cosmo/heartbeat.rbs +2 -0
  71. data/sig/cosmo/job/data.rbs +5 -3
  72. data/sig/cosmo/job/enqueuer.rbs +11 -0
  73. data/sig/cosmo/job/processor.rbs +3 -5
  74. data/sig/cosmo/job/requeue.rbs +9 -0
  75. data/sig/cosmo/job.rbs +0 -2
  76. data/sig/cosmo/logger.rbs +6 -0
  77. data/sig/cosmo/middleware/chain.rbs +51 -0
  78. data/sig/cosmo/middleware/sentry.rbs +14 -0
  79. data/sig/cosmo/middleware.rbs +35 -0
  80. data/sig/cosmo/processor.rbs +2 -0
  81. data/sig/cosmo/publisher.rbs +1 -9
  82. data/sig/cosmo/services.rbs +18 -0
  83. data/sig/cosmo/stream/processor.rbs +2 -0
  84. data/sig/cosmo/utils/hash.rbs +2 -3
  85. data/sig/cosmo.rbs +11 -0
  86. metadata +30 -3
  87. data/lib/cosmo/sentry/auto.rb +0 -6
@@ -6,17 +6,27 @@ module Cosmo
6
6
  # Live worker processes, one entry per process, refreshed by Cosmo::Heartbeat.
7
7
  class Processes < Registry
8
8
  TTL = 60
9
- BUCKET = "cosmo_processes"
9
+ BUCKET = "_cosmoprocesses"
10
+ KINDS = %w[jobs streams jobs+streams none].freeze
10
11
 
11
- # Sorted by host and pid before paging: every heartbeat rewrites an entry, so the bucket's own
12
- # order shifts constantly and would move processes between pages.
12
+ # Which processors a process is pulling for, judged by its non-empty subscriptions.
13
+ #
14
+ # @param process [Hash]
15
+ # @return [String] one of {KINDS}
16
+ def self.kind(process)
17
+ types = Hash(process[:subscriptions]).reject { |_, names| Array(names).empty? }.keys.map(&:to_s).sort
18
+ types.empty? ? "none" : types.join("+")
19
+ end
20
+
21
+ # Sorted by kind, host and pid before paging: every heartbeat rewrites an entry, so the bucket's own
22
+ # order shifts constantly and would move processes between pages, and each kind stays contiguous.
13
23
  #
14
24
  # @param page [Integer, nil]
15
25
  # @param limit [Integer]
16
26
  # @return [Array<Hash>]
17
27
  def list(page: nil, limit: LIMIT)
18
28
  offset = ([page.to_i, 1].max - 1) * limit
19
- all.sort_by { [_1[:hostname].to_s, _1[:pid].to_i] }.slice(offset, limit).to_a
29
+ all.sort_by { [KINDS.index(self.class.kind(_1)) || KINDS.size, _1[:hostname].to_s, _1[:pid].to_i] }.slice(offset, limit).to_a
20
30
  end
21
31
 
22
32
  # @param identity [String] a unique process id, e.g. +hostname-pid+
@@ -3,23 +3,15 @@
3
3
  module Cosmo
4
4
  module API
5
5
  module Stats
6
- # Processed and failed job totals across all workers.
6
+ # Processed and failed job totals across all workers, in the +_cosmototals+ counters. They never expire.
7
7
  class Totals < Counter
8
+ STREAM_NAME = "_cosmototals"
9
+ DESCRIPTION = "Cosmo statistics"
10
+
8
11
  def self.instance
9
12
  @instance ||= new("jobs")
10
13
  end
11
14
 
12
- # Counts the block's outcome: +true+ is processed, +false+ or a raise is failed.
13
- #
14
- # @return [void]
15
- def with
16
- result = yield
17
- increment(:processed) if result == true
18
- increment(:failed) if result == false
19
- rescue Exception # rubocop:disable Lint/RescueException
20
- increment(:failed)
21
- end
22
-
23
15
  # @return [Integer]
24
16
  def processed
25
17
  get(:processed)
@@ -3,6 +3,7 @@
3
3
  require "cosmo/api/stats/totals"
4
4
  require "cosmo/api/stats/busy"
5
5
  require "cosmo/api/stats/processes"
6
+ require "cosmo/api/stats/metrics"
6
7
 
7
8
  module Cosmo
8
9
  module API
@@ -34,11 +35,11 @@ module Cosmo
34
35
  end
35
36
 
36
37
  def scheduled
37
- Stream.new("scheduled").size
38
+ Config.scheduled.enabled ? Stream.new("scheduled").size : 0
38
39
  end
39
40
 
40
41
  def dead
41
- Stream.new("dead").size
42
+ Config.dead.enabled ? Stream.new("dead").size : 0
42
43
  end
43
44
  end
44
45
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/api/counter"
4
+
5
+ module Cosmo
6
+ class Batch
7
+ # Every batch's total, pending, and failed counters, in the +_cosmobatches+ stream. They are purged once the batch
8
+ # finishes, and expire +config.batches.expiry+ after their last update otherwise, along with the batch's KV state.
9
+ class Counters < API::Counter
10
+ STREAM_NAME = "_cosmobatches"
11
+ DESCRIPTION = "Cosmo batch progress"
12
+
13
+ # @return [Hash]
14
+ def self.stream_config
15
+ super.merge(max_age: Config.to_ns(Utils::Duration.parse(Config.batches.expiry)))
16
+ end
17
+ end
18
+ end
19
+ end
data/lib/cosmo/batch.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "securerandom"
4
4
  require "cosmo/batch/callback"
5
5
  require "cosmo/batch/dispatcher"
6
+ require "cosmo/batch/counters"
6
7
 
7
8
  module Cosmo
8
9
  # Groups jobs together and runs a callback once they've all finished.
@@ -51,8 +52,8 @@ module Cosmo
51
52
  # Every batch has an id (bid, a random hex string). State lives
52
53
  # in two places, both keyed by that bid:
53
54
  #
54
- # 1. Three atomic counters (via API::Counter, namespace "batch", backed by
55
- # the shared _cosmostats stream):
55
+ # 1. Three atomic counters (via Batch::Counters, namespace "batch", in the
56
+ # _cosmobatches stream, which expires them with the KV state below):
56
57
  #
57
58
  # <bid>.total - how many jobs have ever joined this batch
58
59
  # <bid>.pending - how many of them are still running
@@ -83,8 +84,7 @@ module Cosmo
83
84
  class Batch
84
85
  extend Dispatcher
85
86
 
86
- BUCKET = "cosmo_jobs_batches"
87
- DEFAULT_EXPIRY = "3d"
87
+ BUCKET = "_cosmobatches"
88
88
  EVENTS = %i[success complete].freeze
89
89
 
90
90
  def self.current
@@ -100,11 +100,11 @@ module Cosmo
100
100
  end
101
101
 
102
102
  def self.counter
103
- @counter ||= API::Counter.new("batch")
103
+ @counter ||= Counters.new("batch")
104
104
  end
105
105
 
106
106
  def self.kv
107
- @kv ||= API::KV.new(BUCKET, ttl: Utils::Duration.parse(Config[:batch_expiry] || DEFAULT_EXPIRY))
107
+ @kv ||= API::KV.new(BUCKET, ttl: Utils::Duration.parse(Config.batches.expiry), replicas: Config.replicas)
108
108
  end
109
109
 
110
110
  attr_reader :bid, :parent_id
data/lib/cosmo/cli.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "yaml"
4
4
  require "optparse"
5
+ require "fileutils"
5
6
 
6
7
  module Cosmo
7
8
  # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
@@ -20,6 +21,7 @@ module Cosmo
20
21
  def run
21
22
  @argv = ARGV.dup
22
23
  flags, command, options = parse
24
+ return run_init if flags[:init]
23
25
  return run_setup(flags) if flags[:setup]
24
26
 
25
27
  load_config(flags)
@@ -57,10 +59,19 @@ module Cosmo
57
59
  path = default_path if File.exist?(default_path)
58
60
  end
59
61
 
60
- Config.load(path)
61
- Config.set(:concurrency, flags[:concurrency]) if flags[:concurrency]
62
- Config.set(:timeout, flags[:timeout]) if flags[:timeout]
63
- Config.set(:http, :port, flags[:http_port]) if flags[:http_port]
62
+ overrides = { concurrency: flags[:concurrency], timeout: flags[:timeout] }.compact
63
+ overrides[:http] = { port: flags[:http_port] } if flags[:http_port]
64
+ Config.load(path, overrides:)
65
+ end
66
+
67
+ def run_init
68
+ path = File.expand_path(Config::DEFAULT_PATH)
69
+ raise Error, "#{Config::DEFAULT_PATH} already exists" if File.exist?(path)
70
+
71
+ FileUtils.mkdir_p(File.dirname(path))
72
+ FileUtils.cp(Config::DEFAULTS_FILE, path)
73
+ puts "Created #{Config::DEFAULT_PATH} with Cosmo's defaults"
74
+ exit(0)
64
75
  end
65
76
 
66
77
  def run_setup(flags)
@@ -80,7 +91,8 @@ module Cosmo
80
91
  puts unless first_line
81
92
  end
82
93
 
83
- API::Counter.setup!
94
+ services = Services.setup!
95
+ puts "Service streams are ready: #{services.join(", ")}" if services.any?
84
96
 
85
97
  schedules = Config.dig(:setup, :cron)&.reduce(0) do |sum, (name, entry)|
86
98
  class_name = entry.delete(:class)
@@ -151,6 +163,10 @@ module Cosmo
151
163
  flags[:setup] = true
152
164
  end
153
165
 
166
+ o.on "--init", "Write #{Config::DEFAULT_PATH} with Cosmo's defaults, then exit" do
167
+ flags[:init] = true
168
+ end
169
+
154
170
  o.on_tail "-v", "--version", "Print version" do
155
171
  puts "Cosmo #{VERSION}"
156
172
  exit(0)
data/lib/cosmo/client.rb CHANGED
@@ -144,6 +144,31 @@ module Cosmo
144
144
  js.get_msg(stream_name, **options)
145
145
  end
146
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
+
147
172
  def delete_message(name, seq)
148
173
  response = nc.request("$JS.API.STREAM.MSG.DELETE.#{name}", JSON.dump({ seq: seq }))
149
174
  Utils::Json.parse(response.data, symbolize_names: false)
@@ -198,7 +223,7 @@ module Cosmo
198
223
 
199
224
  # NOTE: KV manager in nats-pure hardcodes the fields it copies into StreamConfig,
200
225
  # so `allow_msg_ttl` is never forwarded via create_key_value. Send the raw stream-create API request instead.
201
- def create_kv_with_msg_ttl(name, **options)
226
+ def create_kv_with_msg_ttl(name, replicas: 1, **options)
202
227
  payload = Utils::Json.dump({
203
228
  name: "KV_#{name}",
204
229
  subjects: ["$KV.#{name}.>"],
@@ -206,7 +231,8 @@ module Cosmo
206
231
  allow_direct: true,
207
232
  allow_msg_ttl: true,
208
233
  allow_rollup_hdrs: true,
209
- max_msgs_per_subject: 1
234
+ max_msgs_per_subject: 1,
235
+ num_replicas: replicas
210
236
  }.merge(options))
211
237
  resp = nc.request("$JS.API.STREAM.CREATE.KV_#{name}", payload)
212
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,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-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