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
@@ -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
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module Middleware
5
+ # Records every execution for the web UI's Metrics tab while +config.metrics+ is enabled: its run time when it
6
+ # succeeds, a failure otherwise, and the wait from enqueue to the job's first delivery. ActiveJob jobs count under
7
+ # their own class.
8
+ class Metrics
9
+ # @param job [Cosmo::Job]
10
+ # @param data [Hash]
11
+ # @param _message [NATS::Msg]
12
+ def call(job, data, _message) # rubocop:disable Metrics/AbcSize
13
+ return yield unless Config.metrics.enabled
14
+
15
+ name = job_name(job, data)
16
+ wait_ms = (Time.now - job.enqueued_at) * 1000 if job.attempt == 1 && job.enqueued_at
17
+ started = clock
18
+ result = yield
19
+ API::Stats::Metrics.instance.record(name, exec_ms: clock - started, wait_ms:)
20
+ result
21
+ rescue Exception # rubocop:disable Lint/RescueException
22
+ API::Stats::Metrics.instance.record(name, wait_ms:, failed: true) if name
23
+ raise
24
+ end
25
+
26
+ private
27
+
28
+ def job_name(job, data)
29
+ return job.class.name unless defined?(ActiveJobAdapter::Executor) && job.is_a?(ActiveJobAdapter::Executor)
30
+
31
+ data.dig(:args, 0, :job_class).to_s
32
+ end
33
+
34
+ def clock
35
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -2,28 +2,39 @@
2
2
 
3
3
  # rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize
4
4
  module Cosmo
5
- module Sentry
6
- module JobProcessorMiddleware
5
+ module Middleware
6
+ # Wraps every job execution in a Sentry transaction. Errors reach Sentry through {ERROR_HANDLER}, which also
7
+ # captures those outside jobs (stream batches, fetches, the scheduler). Needs +sentry-ruby+, so it is not loaded
8
+ # by default:
9
+ #
10
+ # require "cosmo/middleware/sentry"
11
+ #
12
+ # Cosmo.configure do |config|
13
+ # config.server_middleware { |chain| chain.add Cosmo::Middleware::Sentry }
14
+ # config.error_handlers << Cosmo::Middleware::Sentry::ERROR_HANDLER
15
+ # end
16
+ class Sentry
7
17
  NAME_PREFIX = "Cosmonats"
8
18
  OP_NAME = "queue.cosmonats"
9
19
  SPAN_ORIGIN = "auto.queue.cosmonats"
10
20
  STATUS_OK = 200
11
21
  STATUS_FAIL = 500
12
22
 
13
- # @param job_instance [Cosmo::Job]
23
+ ERROR_HANDLER = lambda do |error, context|
24
+ next unless ::Sentry.initialized?
25
+
26
+ ::Sentry.capture_exception(error, contexts: { cosmonats: context }, hint: { background: true, integration: "cosmonats" })
27
+ end
28
+
29
+ # @param job [Cosmo::Job]
14
30
  # @param data [Hash]
15
31
  # @param message [NATS::Msg]
16
- # @param duration [Float, nil]
17
- def perform_job(job_instance, data:, message:, duration: nil)
18
- unless ::Sentry.initialized?
19
- super
20
- return
21
- end
32
+ def call(job, data, message)
33
+ return yield unless ::Sentry.initialized?
22
34
 
23
35
  scope = ::Sentry.get_current_scope
24
- transaction_name = "#{NAME_PREFIX}/#{job_instance.class.name}"
36
+ transaction_name = "#{NAME_PREFIX}/#{job.class.name}"
25
37
  scope.set_transaction_name(transaction_name, source: :task)
26
-
27
38
  transaction = ::Sentry.start_transaction(
28
39
  name: scope.transaction_name,
29
40
  source: scope.transaction_source,
@@ -35,29 +46,14 @@ module Cosmo
35
46
  transaction&.set_data("messaging.message.retry.count", data[:retry] || 0)
36
47
 
37
48
  begin
38
- super
39
-
49
+ result = yield
40
50
  transaction&.set_http_status(STATUS_OK)
41
51
  transaction&.finish
42
- rescue StandardError => e
43
- ::Sentry.capture_exception(
44
- e,
45
- contexts: {
46
- cosmonats: data.merge(
47
- nats_stream: message.metadata.stream,
48
- nats_subject: message.subject,
49
- timeout_duration: duration
50
- )
51
- },
52
- hint: {
53
- background: true,
54
- integration: "cosmonats"
55
- }
56
- )
52
+ result
53
+ rescue StandardError
57
54
  transaction&.set_http_status(STATUS_FAIL)
58
55
  transaction&.finish
59
-
60
- raise e
56
+ raise
61
57
  end
62
58
  end
63
59
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module Middleware
5
+ # Counts every execution as processed or failed, so a job retried twice and then succeeding adds two failures
6
+ # and one processed. A counter that cannot be written is logged rather than raised, so stats never fail a job.
7
+ class Totals
8
+ # @param _job [Cosmo::Job]
9
+ # @param _data [Hash]
10
+ # @param _message [NATS::Msg]
11
+ def call(_job, _data, _message)
12
+ result = yield
13
+ increment(:processed)
14
+ result
15
+ rescue Exception # rubocop:disable Lint/RescueException
16
+ increment(:failed)
17
+ raise
18
+ end
19
+
20
+ private
21
+
22
+ def increment(key)
23
+ API::Stats::Totals.instance.increment(key)
24
+ rescue StandardError => e
25
+ Logger.debug "Totals #{key} counter error: #{e.class} #{e.message}"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/middleware/chain"
4
+ require "cosmo/middleware/limit"
5
+ require "cosmo/middleware/busy"
6
+ require "cosmo/middleware/totals"
7
+ require "cosmo/middleware/metrics"
@@ -86,9 +86,10 @@ module Cosmo
86
86
  end
87
87
  all_paused = false
88
88
 
89
- _, skip_t = consumer_state[stream_name]
89
+ key = backoff_key(config)
90
+ _, skip_t = consumer_state[key]
90
91
  if skip_t && Time.now < skip_t
91
- Logger.trace "stream #{stream_name} is empty, backing off"
92
+ Logger.trace "#{key} is empty, backing off"
92
93
  next
93
94
  end
94
95
  all_empty = false
@@ -96,7 +97,7 @@ module Cosmo
96
97
  begin
97
98
  @pool.post do
98
99
  # Re-check, after possibly being blocked on post to thread pool
99
- _, skip_t = consumer_state[stream_name]
100
+ _, skip_t = consumer_state[key]
100
101
  next if skip_t && Time.now < skip_t
101
102
 
102
103
  timeout = fetch_timeout(config)
@@ -105,11 +106,11 @@ module Cosmo
105
106
  messages = fetch(subscription, batch_size: config[:batch_size], timeout:)
106
107
  Logger.trace "fetched (#{messages&.size.to_i}) messages"
107
108
  if messages&.any?
108
- consumer_state.delete(stream_name)
109
+ consumer_state.delete(key)
109
110
  track_busy { process(messages, processor) }
110
111
  else
111
112
  max_backoff = Utils::Duration.parse(ENV.fetch("COSMO_STREAM_EMPTY_BACKOFF_MAX", STREAM_EMPTY_BACKOFF_MAX))
112
- consumer_state.compute(stream_name) do |(count, wake_at)|
113
+ consumer_state.compute(key) do |(count, wake_at)|
113
114
  next [count, wake_at] if wake_at && wake_at > started_at
114
115
 
115
116
  count = count.to_i + 1
@@ -179,6 +180,7 @@ module Cosmo
179
180
  rescue StandardError => e
180
181
  Logger.error "Snap! Error just happened"
181
182
  Logger.error "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
183
+ Cosmo.handle_error(e, { source: :fetch })
182
184
 
183
185
  backoff = ENV.fetch("COSMO_STREAMS_FETCH_BACKOFF", 5).to_f
184
186
  sleep([timeout, backoff].max) # backoff before retry
@@ -196,5 +198,9 @@ module Cosmo
196
198
  def consumer_state
197
199
  @consumer_state ||= Concurrent::Map.new
198
200
  end
201
+
202
+ def backoff_key(config)
203
+ config[:stream].to_s
204
+ end
199
205
  end
200
206
  end
@@ -7,7 +7,7 @@ module Cosmo
7
7
  class << self
8
8
  extend Forwardable
9
9
 
10
- delegate %i[publish publish_job publish_batch] => :instance
10
+ delegate %i[publish] => :instance
11
11
  end
12
12
 
13
13
  def self.instance
@@ -22,17 +22,5 @@ module Cosmo
22
22
  payload = (serializer || Stream::Serializer).serialize(data)
23
23
  @client.publish(subject, payload, **options)
24
24
  end
25
-
26
- def publish_job(data)
27
- subject, payload, params = data.to_args
28
- @client.publish(subject, payload, **params)
29
- data.jid
30
- rescue NATS::JetStream::Error::NoStreamResponse
31
- raise StreamNotFoundError, params[:stream].to_s
32
- end
33
-
34
- def publish_batch(subject, batch, **options)
35
- batch.each { publish(subject, _1, **options) }
36
- end
37
25
  end
38
26
  end
data/lib/cosmo/railtie.rb CHANGED
@@ -12,10 +12,10 @@ module Cosmo
12
12
  require "cosmo/active_job"
13
13
  end
14
14
 
15
- # Autoload config/cosmo.yml when it exists and no config has been loaded yet.
15
+ # Loads config/cosmo.yml, or the built-in defaults without one, unless a config has been loaded already.
16
16
  initializer "cosmo.load_config", after: "cosmo.active_job_adapter" do |app|
17
17
  config_path = app.root.join("config", "cosmo.yml")
18
- Config.load(config_path.to_s) if config_path.exist? && Config.instance.none?
18
+ Config.load(config_path.exist? ? config_path.to_s : nil) if Config.instance.none?
19
19
  end
20
20
  end
21
21
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ # The streams Cosmo itself depends on. They are built from the settings in Cosmo.configure, never from cosmo.yml,
5
+ # and `cosmo --setup` creates them next to the user's streams.
6
+ module Services
7
+ SCHEDULED = Job::StreamFilter::SCHEDULED.to_s
8
+ DEAD = "dead"
9
+ METADATA = { "_cosmo.type" => "jobs" }.freeze
10
+ RETENTION_CHANGE = 10_052
11
+
12
+ module_function
13
+
14
+ # Holds delayed jobs and every cron template. It alone enables NATS message scheduling, since a schedule can only
15
+ # target a subject its own stream covers, and NATS refuses +discard: new+ wherever scheduling is enabled.
16
+ #
17
+ # @return [Hash, nil] nil when scheduling is turned off
18
+ def scheduled_stream
19
+ return unless Config.scheduled.enabled
20
+
21
+ { storage: "file", retention: "workqueue", discard: "old", allow_direct: true, allow_msg_schedules: true,
22
+ duplicate_window: Config.to_ns(120), subjects: ["jobs.#{SCHEDULED}.>", "#{API::Cron::Entry::SUBJECT_PREFIX}.>"],
23
+ num_replicas: Config.replicas, description: "Scheduled jobs and cron schedules", metadata: METADATA }
24
+ end
25
+
26
+ # +max_deliver+ stays above 1: the scheduler naks a message that is not yet due, and one whose dispatch failed,
27
+ # and both need another delivery.
28
+ #
29
+ # @return [Hash, nil] nil when scheduling is turned off
30
+ def scheduled_consumer
31
+ return unless Config.scheduled.enabled
32
+
33
+ { ack_policy: "explicit", max_deliver: 5, max_ack_pending: 100, ack_wait: 10, subject: "jobs.#{SCHEDULED}.>" }
34
+ end
35
+
36
+ # @return [Hash, nil] nil when dead-lettering is turned off
37
+ def dead_stream
38
+ dead = Config.dead
39
+ return unless dead.enabled
40
+
41
+ { storage: "file", retention: "workqueue", discard: "old", allow_direct: true, duplicate_window: Config.to_ns(120),
42
+ max_age: Config.to_ns(Utils::Duration.parse(dead.max_age)), max_msgs: dead.max_msgs, max_bytes: dead.max_bytes,
43
+ subjects: ["jobs.#{DEAD}.>"], num_replicas: Config.replicas, description: "Dead jobs", metadata: METADATA }
44
+ end
45
+
46
+ # Creates or updates every enabled service stream, plus the internal totals and batch counters and, while enabled,
47
+ # the job metrics.
48
+ #
49
+ # @return [Array<String>] the names of the service streams set up, without the internal ones
50
+ def setup!
51
+ streams = { SCHEDULED => scheduled_stream, DEAD => dead_stream }.compact
52
+ streams.each { |name, config| setup_stream(name, config) }
53
+ API::Stats::Totals.setup!
54
+ Batch::Counters.setup!
55
+ API::Stats::Metrics.setup! if Config.metrics.enabled
56
+ streams.keys
57
+ end
58
+
59
+ # @raise [Error] when an existing stream has another retention policy, which NATS can't change in place
60
+ def setup_stream(name, config)
61
+ Client.instance.setup_stream(name, config)
62
+ rescue NATS::JetStream::Error::APIError => e
63
+ raise unless e.err_code == RETENTION_CHANGE
64
+
65
+ raise Error, "The existing `#{name}` stream has another retention policy, which NATS can't change in place: " \
66
+ "delete it (`nats stream rm #{name}`, dropping its messages) and rerun `cosmo --setup`"
67
+ end
68
+ end
69
+ end
@@ -38,8 +38,10 @@ module Cosmo
38
38
  processor.process(messages)
39
39
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "done" }
40
40
  rescue StandardError => e
41
- Logger.debug e
41
+ Logger.error e
42
42
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
43
+ Cosmo.handle_error(e, { source: :stream, stream: metadata&.stream, processor: processor.class.name,
44
+ size: messages&.size })
43
45
  rescue Exception # rubocop:disable Lint/RescueException
44
46
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
45
47
  raise
@@ -70,6 +72,10 @@ module Cosmo
70
72
  config.dig(:consumer, :subjects)
71
73
  end
72
74
 
75
+ def backoff_key(config)
76
+ config[:consumer_name] || config[:class].name
77
+ end
78
+
73
79
  def fetch_timeout(config)
74
80
  timeout = config[:fetch_timeout].to_f
75
81
  if timeout <= 0
@@ -36,14 +36,13 @@ module Cosmo
36
36
  end
37
37
  end
38
38
 
39
- # deep set
40
- def set(hash, *keys, value)
41
- last_key = keys.pop
42
- target = keys.reduce(hash) do |base, key|
43
- base[key] ||= {}
44
- base[key]
45
- end
46
- target[last_key] = value
39
+ # Nested hashes merge recursively, +other+ winning; any other value in +other+ replaces the one in +base+.
40
+ #
41
+ # @param base [::Hash]
42
+ # @param other [::Hash]
43
+ # @return [::Hash] a new hash
44
+ def deep_merge(base, other)
45
+ base.merge(other) { |_, old, new| old.is_a?(::Hash) && new.is_a?(::Hash) ? deep_merge(old, new) : new }
47
46
  end
48
47
 
49
48
  # deep dup
data/lib/cosmo/utils.rb CHANGED
@@ -4,7 +4,6 @@ require "cosmo/utils/hash"
4
4
  require "cosmo/utils/json"
5
5
  require "cosmo/utils/string"
6
6
  require "cosmo/utils/signal"
7
- require "cosmo/utils/warnings"
8
7
  require "cosmo/utils/duration"
9
8
  require "cosmo/utils/stopwatch"
10
9
  require "cosmo/utils/thread_pool"
data/lib/cosmo/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cosmo
4
- VERSION = "0.7.0"
4
+ VERSION = "0.9.0"
5
5
  end