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
@@ -10,41 +10,42 @@ module Cosmo
10
10
  # Derives the schedule list entirely from NATS.
11
11
  # Whatever is deployed in NATS is exactly what appears in the UI.
12
12
  #
13
- # Schedule templates live in the same job stream they target (e.g. +default+),
14
- # stored at subjects matching +cosmo.cron.<stream>.>+. NATS 2.14 fires each
15
- # template by publishing the body to +Nats-Schedule-Target+ as a regular
16
- # JetStream message that accumulates alongside pending jobs.
13
+ # Every schedule template lives in the +scheduled+ stream, at a subject matching
14
+ # +cosmo.cron.<target stream>.>+. NATS fires a template by publishing its body to
15
+ # +Nats-Schedule-Target+, which has to be a subject the storing stream covers, so a firing
16
+ # lands back in +scheduled+ and the job processor's scheduler dispatches it to the stream
17
+ # that runs it. That confines message scheduling - and the +discard: old+ NATS demands
18
+ # wherever scheduling is enabled - to a single stream.
17
19
  class Cron
20
+ STREAM = ::Cosmo::Job::StreamFilter::SCHEDULED.to_s
21
+
18
22
  def self.instance
19
23
  @instance ||= new
20
24
  end
21
25
 
22
26
  # @return [Array<Hash>] every cron schedule currently deployed in NATS
23
27
  def all
24
- Stream.jobs.flat_map { |s| schedules_from_stream(s.name) }
28
+ schedules
25
29
  rescue StandardError
26
30
  []
27
31
  end
28
32
 
29
33
  # Publish (or replace) a schedule message in NATS.
30
34
  # @return [Hash, nil] the persisted schedule as a hash, or nil on failure
35
+ # @raise [SchedulingDisabledError] when scheduling is turned off
31
36
  def upsert!(class_name: nil, stream: nil, schedule: nil, args: [], timezone: nil, name: nil)
37
+ raise SchedulingDisabledError unless Config.scheduled.enabled
38
+
32
39
  e = Entry.new(class_name: class_name, stream: stream, expression: schedule,
33
40
  args: args, timezone: timezone, name: name)
34
- headers = {
35
- "Nats-Schedule" => e.expression,
36
- "Nats-Schedule-Target" => e.target_subject
37
- }
38
- headers["Nats-Schedule-Time-Zone"] = e.timezone if e.timezone
39
- client.publish(e.schedule_subject, e.job_payload, stream: e.stream, header: headers)
40
- build_from_nats(e.stream, e.schedule_subject)
41
+ client.publish(e.schedule_subject, e.job_payload, stream: STREAM, header: e.schedule_headers)
42
+ build_from_nats(e.schedule_subject)
41
43
  end
42
44
 
43
45
  # Purge the schedule message from NATS (stops future firings).
44
46
  # @param subject [String]
45
47
  def delete!(subject)
46
- stream_name = subject.to_s.split(".")[2]
47
- client.purge(stream_name, subject)
48
+ client.purge(STREAM, subject)
48
49
  rescue NATS::JetStream::Error::NotFound, NATS::IO::Timeout
49
50
  nil
50
51
  end
@@ -52,13 +53,12 @@ module Cosmo
52
53
  # Dispatch the job immediately to the target stream, bypassing the timer.
53
54
  # @param schedule_subject [String] e.g. "cosmo.cron.default.report_job.daily"
54
55
  def run_now!(schedule_subject) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
55
- stream_name = schedule_subject.to_s.split(".")[2]
56
- msg = client.get_message(stream_name, subject: schedule_subject)
56
+ msg = client.get_message(STREAM, subject: schedule_subject)
57
57
  return unless msg
58
58
 
59
59
  headers = msg.headers || {}
60
60
  body = Utils::Json.parse(msg.data) || {}
61
- target = headers["Nats-Schedule-Target"]
61
+ target = headers["X-Subject"]
62
62
  return unless target && body[:class]
63
63
 
64
64
  payload = Utils::Json.dump({
@@ -68,7 +68,7 @@ module Cosmo
68
68
  retry: body[:retry] || Job::Data.default_retry,
69
69
  dead: body[:dead].nil? ? Job::Data::DEFAULTS[:dead] : body[:dead]
70
70
  })
71
- client.publish(target, payload, stream: stream_name)
71
+ client.publish(target, payload, stream: headers["X-Stream"])
72
72
  rescue NATS::JetStream::Error::NotFound
73
73
  nil
74
74
  end
@@ -79,14 +79,13 @@ module Cosmo
79
79
  @client ||= Client.instance
80
80
  end
81
81
 
82
- def schedules_from_stream(stream_name)
83
- filter = "#{Entry::SUBJECT_PREFIX}.#{stream_name}.>"
84
- subjects = client.cron_subjects_in_stream(stream_name, filter)
85
- subjects.filter_map { |subj| build_from_nats(stream_name, subj) }
82
+ def schedules
83
+ subjects = client.cron_subjects_in_stream(STREAM, "#{Entry::SUBJECT_PREFIX}.>")
84
+ subjects.filter_map { |subj| build_from_nats(subj) }
86
85
  end
87
86
 
88
- def build_from_nats(stream_name, subject)
89
- msg = client.get_message(stream_name, subject: subject)
87
+ def build_from_nats(subject)
88
+ msg = client.get_message(STREAM, subject: subject)
90
89
  return unless msg
91
90
 
92
91
  headers = msg.headers || {}
@@ -94,13 +93,13 @@ module Cosmo
94
93
 
95
94
  {
96
95
  class: body[:class],
97
- stream: stream_name,
96
+ stream: headers["X-Stream"],
98
97
  schedule: headers["Nats-Schedule"],
99
98
  timezone: headers["Nats-Schedule-Time-Zone"],
100
99
  args: body[:args] || [],
101
100
  name: name_from_subject(subject),
102
101
  schedule_subject: subject,
103
- target_subject: headers["Nats-Schedule-Target"],
102
+ dispatch_subject: headers["X-Subject"],
104
103
  registry_key: subject.split(".").drop(2).join("/")
105
104
  }
106
105
  rescue StandardError
data/lib/cosmo/api/job.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
4
+
3
5
  module Cosmo
4
6
  module API
5
7
  class Job
@@ -38,9 +40,37 @@ module Cosmo
38
40
  @message.subject
39
41
  end
40
42
 
43
+ def error_class
44
+ headers&.dig("X-Error-Class")
45
+ end
46
+
47
+ def error_message
48
+ headers&.dig("X-Error-Message")
49
+ end
50
+
51
+ def error_backtrace
52
+ headers&.dig("X-Error-Backtrace")
53
+ end
54
+
55
+ # @return [Array<String>] the backtrace frames, which travel as a single header line
56
+ def error_backtrace_lines
57
+ error_backtrace.to_s.split(::Cosmo::Job::Failure::BACKTRACE_SEPARATOR)
58
+ end
59
+
60
+ def error?
61
+ !error_class.to_s.empty? || !error_message.to_s.empty?
62
+ end
63
+
41
64
  def timestamp
42
65
  headers&.dig("Nats-Time-Stamp")
43
66
  end
67
+
68
+ # @return [Time, nil] when the message was stored in its stream, parsed from +Nats-Time-Stamp+
69
+ def stored_at
70
+ Time.iso8601(timestamp) if timestamp
71
+ rescue ::ArgumentError
72
+ nil
73
+ end
44
74
  end
45
75
  end
46
76
  end
data/lib/cosmo/api/kv.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  module Cosmo
4
4
  module API
5
5
  class KV
6
+ LIMIT = 25
7
+
6
8
  attr_reader :kv
7
9
 
8
10
  def initialize(name, options = nil)
@@ -39,17 +41,19 @@ module Cosmo
39
41
  kv.delete(key)
40
42
  end
41
43
 
42
- def keys(subject = nil, limit: 25)
43
- results = []
44
- watcher = kv.watch(subject || ">", ignore_deletes: true, meta_only: true)
45
- watcher.each do |entry|
46
- break unless entry
44
+ # Walks the bucket and collects live keys. Pass +limit: nil+ to walk the whole
45
+ # bucket -- a bounded +limit+ stops the watch early, so it can never be used
46
+ # to count (see #count).
47
+ def keys(subject = nil, limit: LIMIT, offset: 0)
48
+ walk(subject, limit:, offset:, meta_only: true).map(&:first)
49
+ end
47
50
 
48
- results << entry.key
49
- break if results.size >= limit
50
- end
51
- watcher.stop
52
- results
51
+ # Same walk as #keys, but carries each value back with its key. The watch
52
+ # already has the values in hand, so callers that need them get one pass
53
+ # instead of a #keys walk followed by a #get per key.
54
+ # @return [Array<Array(String, String)>] +[key, value]+ pairs
55
+ def entries(subject = nil, limit: LIMIT, offset: 0)
56
+ walk(subject, limit:, offset:, meta_only: false)
53
57
  end
54
58
 
55
59
  # Writes a KV-Operation tombstone (same issue as #delete on ttl buckets).
@@ -70,7 +74,7 @@ module Cosmo
70
74
  end
71
75
 
72
76
  def count
73
- keys.size
77
+ keys(limit: nil).size
74
78
  rescue NATS::KeyValue::NoKeysFoundError, NATS::JetStream::Error::NotFound
75
79
  0
76
80
  end
@@ -78,6 +82,22 @@ module Cosmo
78
82
 
79
83
  private
80
84
 
85
+ def walk(subject, limit:, offset:, meta_only:)
86
+ results = []
87
+ skipped = 0
88
+ watcher = kv.watch(subject || ">", ignore_deletes: true, meta_only:)
89
+ watcher.each do |entry|
90
+ break unless entry
91
+
92
+ next skipped += 1 if skipped < offset.to_i
93
+
94
+ results << [entry.key, entry.value]
95
+ break if limit && results.size >= limit
96
+ end
97
+ watcher.stop
98
+ results
99
+ end
100
+
81
101
  # CAS = Compare-And-Swap: publish +value+ with a per-message Nats-TTL,
82
102
  # but only if the subject's current last sequence matches +last_seq+
83
103
  # (sent as the Nats-Expected-Last-Subject-Sequence header). Raises
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module API
5
+ # Base for a KV bucket of JSON entries that live only while their writer keeps refreshing them:
6
+ # a writer re-puts its entries faster than the bucket's TTL, and an entry whose writer is gone expires.
7
+ # Subclasses define +BUCKET+ and +TTL+.
8
+ class Registry
9
+ LIMIT = 25
10
+
11
+ def self.instance
12
+ @instance ||= new
13
+ end
14
+
15
+ def initialize
16
+ @kv = bucket
17
+ end
18
+
19
+ # Writes or refreshes an entry, recreating the bucket if it was deleted under a running writer.
20
+ #
21
+ # @param key [String, Integer]
22
+ # @param value [Hash]
23
+ # @return [void]
24
+ def put(key, value)
25
+ @kv.set(sanitize(key), Utils::Json.dump(value))
26
+ rescue NATS::JetStream::Error::NoStreamResponse
27
+ @kv = bucket
28
+ @kv.set(sanitize(key), Utils::Json.dump(value))
29
+ end
30
+
31
+ # Removes an entry, leaving no tombstone behind.
32
+ #
33
+ # @param key [String, Integer]
34
+ # @return [void]
35
+ def remove(key)
36
+ @kv.erase(sanitize(key))
37
+ end
38
+
39
+ # @param page [Integer, nil]
40
+ # @param limit [Integer]
41
+ # @return [Array<Hash>]
42
+ def list(page: nil, limit: LIMIT)
43
+ offset = ([page.to_i, 1].max - 1) * limit
44
+ @kv.entries(limit:, offset:).filter_map { Utils::Json.parse(_1.last) }
45
+ end
46
+
47
+ # @return [Array<Hash>] every live entry
48
+ def all
49
+ @kv.entries(limit: nil).filter_map { Utils::Json.parse(_1.last) }
50
+ end
51
+
52
+ # @return [Integer] the number of live entries
53
+ def size
54
+ @kv.size
55
+ end
56
+
57
+ private
58
+
59
+ def bucket
60
+ KV.new(self.class::BUCKET, { ttl: self.class::TTL, replicas: Config.replicas })
61
+ end
62
+
63
+ def sanitize(key)
64
+ key.to_s.gsub(/[^\w.=-]/, "_")
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Cosmo
6
+ module API
7
+ module Stats
8
+ # Jobs being executed right now, one entry per in-flight message, across all workers.
9
+ class Busy < Registry
10
+ TTL = 70
11
+ HEARTBEAT = 30
12
+ BUCKET = "_cosmobusy"
13
+
14
+ def initialize
15
+ super
16
+ @messages = {}
17
+ end
18
+
19
+ def with(message)
20
+ add(message)
21
+ yield
22
+ ensure
23
+ delete(message)
24
+ end
25
+
26
+ def add(message)
27
+ @thread ||= Thread.new { heartbeat_loop }
28
+ meta = message.metadata
29
+ seq = meta.sequence.stream
30
+ entry = { data: message.data, stream: meta.stream, worker: worker_id,
31
+ started_at: Time.now.to_i, delivery: meta.num_delivered }
32
+ @messages[seq] = entry
33
+ put(seq, entry)
34
+ end
35
+
36
+ def delete(message)
37
+ seq = message.metadata.sequence.stream
38
+ @messages.delete(seq)
39
+ remove(seq)
40
+ end
41
+
42
+ def list(...)
43
+ super.map { _1.merge(data: Utils::Json.parse(_1[:data])) }
44
+ end
45
+
46
+ private
47
+
48
+ def heartbeat_loop
49
+ loop do
50
+ sleep(HEARTBEAT)
51
+ @messages.dup.each { |seq, entry| put(seq, entry) rescue StandardError }
52
+ rescue StandardError => e
53
+ Logger.debug "Busy heartbeat error: #{e.class} #{e.message}"
54
+ end
55
+ end
56
+
57
+ def worker_id
58
+ @worker_id ||= "#{Socket.gethostname}-#{::Process.pid}"
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module API
5
+ module Stats
6
+ # Per-day metrics for every job class: runs, failures, and execution and wait time, kept in their own counters
7
+ # stream for +config.metrics.retention+. Workers {#record} executions in memory and the heartbeat {#flush}es them,
8
+ # so a job costs no NATS round trip; a crashed worker loses at most one heartbeat's worth.
9
+ class Metrics < Counter
10
+ STREAM_NAME = "_cosmometrics"
11
+ DESCRIPTION = "Cosmo metrics"
12
+ DAY = 86_400
13
+
14
+ def self.instance
15
+ @instance ||= new
16
+ end
17
+
18
+ # Each day's counters expire +config.metrics.retention+ after their last update.
19
+ #
20
+ # @return [Hash]
21
+ def self.stream_config
22
+ super.merge(max_age: Config.to_ns(Utils::Duration.parse(Config.metrics.retention)))
23
+ end
24
+
25
+ def initialize
26
+ super("jobs")
27
+ @lock = Mutex.new
28
+ @buffer = buffer
29
+ end
30
+
31
+ # Adds one execution to the buffer {#flush} writes.
32
+ #
33
+ # @param job_class [String]
34
+ # @param exec_ms [Numeric, nil] how long a successful execution took
35
+ # @param wait_ms [Numeric, nil] time from enqueue to the job's first delivery
36
+ # @param failed [Boolean]
37
+ # @return [void]
38
+ def record(job_class, exec_ms: nil, wait_ms: nil, failed: false)
39
+ key = [Time.now.utc.strftime("%Y%m%d"), job_class.gsub("::", "-")]
40
+ @lock.synchronize do
41
+ totals = @buffer[key]
42
+ failed ? totals[:failed] += 1 : totals[:count] += 1
43
+ totals[:exec_ms] += exec_ms if exec_ms
44
+ next unless wait_ms
45
+
46
+ totals[:waited] += 1
47
+ totals[:wait_ms] += wait_ms
48
+ end
49
+ end
50
+
51
+ # Writes the buffered executions to the counters and empties the buffer.
52
+ #
53
+ # @return [void]
54
+ def flush
55
+ pending = @lock.synchronize { @buffer.tap { @buffer = buffer } }
56
+ pending.each do |(day, job), totals|
57
+ totals.each { |field, value| increment("#{day}.#{job}.#{field}", by: value.round) if value.positive? }
58
+ end
59
+ end
60
+
61
+ # @param days [Integer] today and the days before it
62
+ # @return [Array<Hash>] per job class +:job+, +:count+, +:failed+, +:exec_ms+ and +:wait_ms+ (averages in
63
+ # milliseconds, nil without data), busiest first
64
+ def summary(days: 1)
65
+ totals = Hash.new { |hash, job| hash[job] = Hash.new(0) }
66
+ read(days).each { |(_, job, field), value| totals[job][field] += value }
67
+ totals.map { |job, sums| row(sums).merge(job: job) }.sort_by { -_1[:count] }
68
+ end
69
+
70
+ # @param job_class [String]
71
+ # @param days [Integer]
72
+ # @return [Array<Hash>] one +{ date:, count:, failed:, exec_ms:, wait_ms: }+ per day, oldest first
73
+ def daily(job_class, days: 30)
74
+ sums = Hash.new { |hash, day| hash[day] = Hash.new(0) }
75
+ read(days).each { |(day, job, field), value| sums[day][field] += value if job == job_class }
76
+ dates(days).map { |day| row(sums[day]).merge(date: day) }
77
+ end
78
+
79
+ private
80
+
81
+ def buffer
82
+ Hash.new { |hash, key| hash[key] = Hash.new(0) }
83
+ end
84
+
85
+ def read(days)
86
+ filters = dates(days).map { "#{STREAM_NAME}.#{@namespace}.#{_1}.>" }
87
+ client.last_messages(STREAM_NAME, filters).map do |subject, data|
88
+ day, job, field = subject.split(".").last(3)
89
+ [[day, job.gsub("-", "::"), field.to_sym], Utils::Json.parse(data, default: {})[:val].to_i]
90
+ end
91
+ end
92
+
93
+ def dates(days)
94
+ now = Time.now.utc
95
+ (days - 1).downto(0).map { (now - (_1 * DAY)).strftime("%Y%m%d") }
96
+ end
97
+
98
+ def row(sums)
99
+ { count: sums[:count], failed: sums[:failed], exec_ms: average(sums[:exec_ms], sums[:count]),
100
+ wait_ms: average(sums[:wait_ms], sums[:waited]) }
101
+ end
102
+
103
+ def average(total, count)
104
+ total.fdiv(count).round(1) if count.positive?
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module API
5
+ module Stats
6
+ # Live worker processes, one entry per process, refreshed by Cosmo::Heartbeat.
7
+ class Processes < Registry
8
+ TTL = 60
9
+ BUCKET = "_cosmoprocesses"
10
+ KINDS = %w[jobs streams jobs+streams none].freeze
11
+
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.
23
+ #
24
+ # @param page [Integer, nil]
25
+ # @param limit [Integer]
26
+ # @return [Array<Hash>]
27
+ def list(page: nil, limit: LIMIT)
28
+ offset = ([page.to_i, 1].max - 1) * limit
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
30
+ end
31
+
32
+ # @param identity [String] a unique process id, e.g. +hostname-pid+
33
+ # @param info [Hash] the process details shown in the Web UI
34
+ # @return [void]
35
+ def register(identity, info)
36
+ put(identity, info)
37
+ end
38
+
39
+ # @param identity [String]
40
+ # @return [void]
41
+ def unregister(identity)
42
+ remove(identity)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module API
5
+ module Stats
6
+ # Processed and failed job totals across all workers, in the +_cosmototals+ counters. They never expire.
7
+ class Totals < Counter
8
+ STREAM_NAME = "_cosmototals"
9
+ DESCRIPTION = "Cosmo statistics"
10
+
11
+ def self.instance
12
+ @instance ||= new("jobs")
13
+ end
14
+
15
+ # @return [Integer]
16
+ def processed
17
+ get(:processed)
18
+ end
19
+
20
+ # @return [Integer]
21
+ def failed
22
+ get(:failed)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "cosmo/api/counter"
4
- require "cosmo/api/busy"
3
+ require "cosmo/api/stats/totals"
4
+ require "cosmo/api/stats/busy"
5
+ require "cosmo/api/stats/processes"
6
+ require "cosmo/api/stats/metrics"
5
7
 
6
8
  module Cosmo
7
9
  module API
@@ -13,11 +15,11 @@ module Cosmo
13
15
  end
14
16
 
15
17
  def processed
16
- Counter.instance.get(:processed)
18
+ Totals.instance.processed
17
19
  end
18
20
 
19
21
  def failed
20
- Counter.instance.get(:failed)
22
+ Totals.instance.failed
21
23
  end
22
24
 
23
25
  def busy
@@ -33,11 +35,11 @@ module Cosmo
33
35
  end
34
36
 
35
37
  def scheduled
36
- Stream.new("scheduled").size
38
+ Config.scheduled.enabled ? Stream.new("scheduled").size : 0
37
39
  end
38
40
 
39
41
  def dead
40
- Stream.new("dead").size
42
+ Config.dead.enabled ? Stream.new("dead").size : 0
41
43
  end
42
44
  end
43
45
  end
@@ -36,7 +36,7 @@ module Cosmo
36
36
 
37
37
  def total
38
38
  all_msgs = info[:state].messages.to_i
39
- cron_count = Client.instance.cron_subjects_in_stream(name, "#{Cron::Entry::SUBJECT_PREFIX}.#{name}.>").size
39
+ cron_count = Client.instance.cron_subjects_in_stream(name, "#{Cron::Entry::SUBJECT_PREFIX}.>").size
40
40
  [all_msgs - cron_count, 0].max
41
41
  rescue NATS::Error
42
42
  0
data/lib/cosmo/api.rb CHANGED
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "cosmo/api/stream"
4
- require "cosmo/api/counter"
5
3
  require "cosmo/api/kv"
4
+ require "cosmo/api/counter"
5
+ require "cosmo/api/registry"
6
+ require "cosmo/api/stream"
6
7
  require "cosmo/api/stats"
7
8
  require "cosmo/api/cron"
8
9
  require "cosmo/api/batch"
@@ -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