cosmonats 0.1.3 → 0.2.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -7
  3. data/lib/cosmo/api/busy.rb +66 -0
  4. data/lib/cosmo/api/counter.rb +70 -0
  5. data/lib/cosmo/api/job.rb +46 -0
  6. data/lib/cosmo/api/kv.rb +63 -0
  7. data/lib/cosmo/api/stats.rb +44 -0
  8. data/lib/cosmo/api/stream.rb +110 -0
  9. data/lib/cosmo/api.rb +11 -0
  10. data/lib/cosmo/cli.rb +6 -4
  11. data/lib/cosmo/client.rb +35 -2
  12. data/lib/cosmo/config.rb +8 -6
  13. data/lib/cosmo/defaults.yml +31 -30
  14. data/lib/cosmo/job/processor.rb +58 -19
  15. data/lib/cosmo/job.rb +1 -1
  16. data/lib/cosmo/logger.rb +4 -0
  17. data/lib/cosmo/processor.rb +7 -1
  18. data/lib/cosmo/stream/data.rb +1 -0
  19. data/lib/cosmo/stream/processor.rb +18 -3
  20. data/lib/cosmo/stream.rb +2 -2
  21. data/lib/cosmo/utils/overrides.rb +15 -0
  22. data/lib/cosmo/utils/warnings.rb +17 -0
  23. data/lib/cosmo/utils.rb +14 -0
  24. data/lib/cosmo/version.rb +1 -1
  25. data/lib/cosmo/web/assets/app.css +431 -0
  26. data/lib/cosmo/web/assets/htmx.2.0.8.min.js.gz +0 -0
  27. data/lib/cosmo/web/context.rb +28 -0
  28. data/lib/cosmo/web/controllers/actions.rb +16 -0
  29. data/lib/cosmo/web/controllers/application.rb +43 -0
  30. data/lib/cosmo/web/controllers/jobs.rb +97 -0
  31. data/lib/cosmo/web/controllers/streams.rb +44 -0
  32. data/lib/cosmo/web/helpers/application.rb +76 -0
  33. data/lib/cosmo/web/renderer.rb +58 -0
  34. data/lib/cosmo/web/views/actions/index.erb +7 -0
  35. data/lib/cosmo/web/views/jobs/_busy.erb +50 -0
  36. data/lib/cosmo/web/views/jobs/_dead.erb +65 -0
  37. data/lib/cosmo/web/views/jobs/_enqueued.erb +60 -0
  38. data/lib/cosmo/web/views/jobs/_scheduled.erb +49 -0
  39. data/lib/cosmo/web/views/jobs/_stats.erb +69 -0
  40. data/lib/cosmo/web/views/jobs/busy.erb +16 -0
  41. data/lib/cosmo/web/views/jobs/dead.erb +17 -0
  42. data/lib/cosmo/web/views/jobs/enqueued.erb +16 -0
  43. data/lib/cosmo/web/views/jobs/index.erb +12 -0
  44. data/lib/cosmo/web/views/jobs/scheduled.erb +17 -0
  45. data/lib/cosmo/web/views/layout.erb +33 -0
  46. data/lib/cosmo/web/views/streams/_info.erb +89 -0
  47. data/lib/cosmo/web/views/streams/_table.erb +42 -0
  48. data/lib/cosmo/web/views/streams/index.erb +11 -0
  49. data/lib/cosmo/web/views/streams/info.erb +11 -0
  50. data/lib/cosmo/web.rb +66 -0
  51. data/lib/cosmo.rb +2 -7
  52. data/sig/cosmo/api/busy.rbs +35 -0
  53. data/sig/cosmo/api/counter.rbs +34 -0
  54. data/sig/cosmo/api/job.rbs +31 -0
  55. data/sig/cosmo/api/kv.rbs +30 -0
  56. data/sig/cosmo/api/stats.rbs +21 -0
  57. data/sig/cosmo/api/stream.rbs +44 -0
  58. data/sig/cosmo/client.rbs +13 -3
  59. data/sig/cosmo/processor.rbs +1 -1
  60. data/sig/cosmo/stream/data.rbs +1 -1
  61. data/sig/cosmo/stream/processor.rbs +2 -0
  62. data/sig/cosmo/stream.rbs +1 -0
  63. metadata +59 -3
  64. /data/sig/cosmo/{message.rbs → stream/message.rbs} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c5a169718d8f3ddb85e2fcc95a5e1b6c0fd3061c1ad4410bd7bad85add934c6
4
- data.tar.gz: aa9715564bdbead26f876ff9de8c9119084015fb3e0d52e2ec6b126c3d3ad0a0
3
+ metadata.gz: b63356c69b61ea32b4791b519830003e4f42330d39947a332cfbfdb20ed91c7d
4
+ data.tar.gz: 6cc0401b58b06038dcc4dc7e4bea9ff695662fa1b251b89fd681a66e893387c7
5
5
  SHA512:
6
- metadata.gz: 41bf7cc2664b566e0f70f9b4045a921abe7afc0e0cd33897f46dd975d4148aead93eaf7faeaffd7364268acee1713b08daeb151c7b124412de2983970787f97b
7
- data.tar.gz: 761a89e2379601c71b75e6c13f63e0c7c0dda9fa7f5631aa6b0f8bab7493f3fe29bee1e562677be8f22ac45b1597f4a0f34660b91e999a7eceb7367d0db50d33
6
+ metadata.gz: 5c7ef63abf649154cb9a5e5662e29297cd4b171810f819b16401f08651a5ee3a8c3111421ed3873ea4c12477d05cd6329e18f67feda91edadf7605eadf2a4b91
7
+ data.tar.gz: d1bb560e31a2c0fb6c321d52599a543a372f184e136f29778a44451648eaa7beed1405d83d1fe996f87b8ee029432d8f395c3fad348a7eaacb73de958ad31573
data/README.md CHANGED
@@ -248,18 +248,19 @@ consumers:
248
248
  max_deliver: 3
249
249
  subjects: ["events.>"]
250
250
 
251
- streams:
252
- my_stream:
253
- storage: file # or memory
254
- retention: workqueue # or limits
255
- max_age: 86400 # 1d in seconds
256
- subjects: ["events.>"]
251
+ setup:
252
+ streams:
253
+ my_stream:
254
+ storage: file # or memory
255
+ retention: workqueue # or limits
256
+ max_age: 86400 # 1d in seconds
257
+ subjects: ["events.>"]
257
258
  ```
258
259
 
259
260
  **Programmatic:**
260
261
  ```ruby
261
262
  Cosmo::Config.set(:concurrency, 20)
262
- Cosmo::Config.set(:streams, :custom, { storage: 'file', subjects: ['custom.>'] })
263
+ Cosmo::Config.set(:setup, :streams, :custom, { storage: 'file', subjects: ['custom.>'] })
263
264
  ```
264
265
 
265
266
  **Environment variables:**
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Cosmo
6
+ module API
7
+ class Busy
8
+ TTL = 70
9
+ HEARTBEAT = 30
10
+ BUCKET = "cosmostats"
11
+
12
+ def self.instance
13
+ @instance ||= new
14
+ end
15
+
16
+ def initialize
17
+ @messages = {}
18
+ @kv = KV.new(BUCKET, { ttl: TTL })
19
+ end
20
+
21
+ def with(message)
22
+ add(message)
23
+ yield
24
+ ensure
25
+ delete(message)
26
+ end
27
+
28
+ def add(message)
29
+ @thread ||= Thread.new { heartbeat_loop }
30
+ seq = message.metadata.sequence.stream
31
+ value = Utils::Json.dump({ data: message.data, stream: message.metadata.stream, worker: worker_id, started_at: Time.now.to_i })
32
+ @messages[seq] = value
33
+ @kv.set(seq, value)
34
+ end
35
+
36
+ def delete(message)
37
+ seq = message.metadata.sequence.stream
38
+ @messages.delete(seq)
39
+ @kv.purge(seq)
40
+ end
41
+
42
+ def list(limit: 25)
43
+ @kv.keys(limit:).filter_map { Utils::Json.parse(@kv.get(_1)) }.map { _1.merge(data: Utils::Json.parse(_1[:data])) }
44
+ end
45
+
46
+ def size
47
+ @kv.size
48
+ end
49
+
50
+ private
51
+
52
+ def heartbeat_loop
53
+ loop do
54
+ sleep(HEARTBEAT)
55
+ @messages.dup.each { |seq, value| @kv.set(seq, value) rescue StandardError }
56
+ rescue StandardError => e
57
+ Logger.debug "Busy heartbeat error: #{e.class} #{e.message}"
58
+ end
59
+ end
60
+
61
+ def worker_id
62
+ @worker_id ||= "#{Socket.gethostname}-#{Process.pid}"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module API
5
+ class Counter
6
+ STREAM_NAME = "cosmostats"
7
+
8
+ def self.instance
9
+ @instance ||= new("jobs")
10
+ end
11
+
12
+ def initialize(namespace)
13
+ @namespace = namespace
14
+ end
15
+
16
+ def with
17
+ result = yield
18
+ increment(:processed) if result == true
19
+ increment(:failed) if result == false
20
+ rescue Exception # rubocop:disable Lint/RescueException
21
+ increment(:failed)
22
+ end
23
+
24
+ def increment(key, by: 1)
25
+ publish(key, "+#{by}")
26
+ end
27
+ alias incr increment
28
+
29
+ def decrement(key, by: 1)
30
+ publish(key, "-#{by}")
31
+ end
32
+ alias decr decrement
33
+
34
+ def reset(key)
35
+ client.purge(STREAM_NAME, subject(key))
36
+ end
37
+
38
+ def get(key)
39
+ raw = client.get_message(STREAM_NAME, direct: true, subject: subject(key))
40
+ Utils::Json.parse(raw.data, default: { "val" => 0 })[:val].to_i
41
+ rescue NATS::JetStream::Error::NotFound, NATS::JetStream::Error::ServiceUnavailable
42
+ 0
43
+ end
44
+
45
+ private
46
+
47
+ def publish(key, value)
48
+ rescued = nil
49
+
50
+ begin
51
+ client.publish(subject(key), "", header: { "Nats-Incr" => value }).val.to_i
52
+ rescue NATS::JetStream::Error::NoStreamResponse
53
+ raise if rescued
54
+
55
+ rescued = true
56
+ client.create_stream(STREAM_NAME, subjects: ["#{STREAM_NAME}.>"], allow_msg_counter: true, allow_direct: true, description: "Cosmo statistics")
57
+ retry
58
+ end
59
+ end
60
+
61
+ def subject(key)
62
+ "#{STREAM_NAME}.#{@namespace}.#{key}"
63
+ end
64
+
65
+ def client
66
+ @client ||= Client.instance
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module API
5
+ class Job
6
+ attr_reader :message, :stream
7
+
8
+ def initialize(stream, message)
9
+ @stream = stream
10
+ @message = message
11
+ end
12
+
13
+ def data
14
+ @data ||= Utils::Json.parse(@message.data)
15
+ end
16
+
17
+ def seq
18
+ @message.seq
19
+ end
20
+
21
+ def headers
22
+ @message.headers
23
+ end
24
+
25
+ def execute_at
26
+ headers&.dig("X-Execute-At")&.to_i
27
+ end
28
+
29
+ def x_stream
30
+ headers&.dig("X-Stream")
31
+ end
32
+
33
+ def x_subject
34
+ headers&.dig("X-Subject")
35
+ end
36
+
37
+ def subject
38
+ @message.subject
39
+ end
40
+
41
+ def timestamp
42
+ headers&.dig("Nats-Time-Stamp")
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module API
5
+ class KV
6
+ def initialize(name, options = nil)
7
+ @name = name
8
+ @options = Hash(options)
9
+ end
10
+
11
+ def set(key, value)
12
+ kv.put(key, value.to_s)
13
+ end
14
+
15
+ def get(key)
16
+ kv.get(key).value
17
+ rescue NATS::KeyValue::KeyNotFoundError
18
+ # nop
19
+ end
20
+
21
+ def delete(key)
22
+ kv.delete(key)
23
+ end
24
+
25
+ def keys(subject = nil, limit: 25)
26
+ results = []
27
+ params = { ignore_deletes: true, meta_only: true }
28
+ watcher = kv.watch(subject || ">", params)
29
+
30
+ watcher.each do |entry|
31
+ break unless entry
32
+
33
+ results << entry.key
34
+ break if results.size >= limit
35
+ end
36
+ watcher.stop
37
+
38
+ results
39
+ end
40
+
41
+ def purge(key)
42
+ kv.purge(key)
43
+ end
44
+
45
+ def clean
46
+ Client.instance.purge("KV_#{@name}", ">")
47
+ end
48
+
49
+ def count
50
+ keys.size
51
+ rescue NATS::KeyValue::NoKeysFoundError
52
+ 0
53
+ end
54
+ alias size count
55
+
56
+ private
57
+
58
+ def kv
59
+ @kv ||= Client.instance.kv(@name, **@options)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/api/counter"
4
+ require "cosmo/api/busy"
5
+
6
+ module Cosmo
7
+ module API
8
+ module Stats
9
+ module_function
10
+
11
+ def summary
12
+ { processed:, failed:, busy:, enqueued:, retries:, scheduled:, dead: }
13
+ end
14
+
15
+ def processed
16
+ Counter.instance.get(:processed)
17
+ end
18
+
19
+ def failed
20
+ Counter.instance.get(:failed)
21
+ end
22
+
23
+ def busy
24
+ Busy.instance.size
25
+ end
26
+
27
+ def enqueued
28
+ Stream.jobs.sum(&:size)
29
+ end
30
+
31
+ def retries
32
+ Stream.jobs.sum(&:retries)
33
+ end
34
+
35
+ def scheduled
36
+ Stream.new("scheduled").size
37
+ end
38
+
39
+ def dead
40
+ Stream.new("dead").size
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/api/job"
4
+
5
+ module Cosmo
6
+ module API
7
+ class Stream
8
+ LIMIT = 20
9
+
10
+ include Enumerable
11
+
12
+ def self.all
13
+ client.list_streams.filter_map { new(_1) }
14
+ end
15
+
16
+ def self.jobs
17
+ names = Config[:setup][:jobs].keys - %i[scheduled dead]
18
+ all.select { names.include?(_1.name.to_sym) }
19
+ end
20
+
21
+ def self.client
22
+ @client ||= Client.instance
23
+ end
24
+
25
+ attr_reader :name
26
+
27
+ def initialize(name)
28
+ @name = name
29
+ end
30
+
31
+ def info
32
+ info = client.stream_info(name)
33
+ { state: info.state, config: info.config }
34
+ end
35
+
36
+ def total
37
+ info[:state].messages.to_i
38
+ rescue StandardError
39
+ 0
40
+ end
41
+ alias size total
42
+
43
+ def retries
44
+ client.list_consumers(name).sum { it["num_redelivered"].to_i }
45
+ end
46
+
47
+ def each
48
+ return if total.zero?
49
+
50
+ state = info[:state]
51
+ current = @offset || state.first_seq.to_i
52
+ last = state.last_seq.to_i
53
+
54
+ loop do
55
+ break if current > last
56
+
57
+ job = message(current)
58
+ break unless job
59
+
60
+ yield job
61
+ current += 1
62
+ end
63
+ end
64
+
65
+ def offset(value)
66
+ @offset = value.to_i
67
+ self
68
+ end
69
+
70
+ def messages(page: nil, limit: nil)
71
+ jobs = []
72
+ limit = (limit || LIMIT).to_i
73
+ state = info[:state]
74
+ start = state.first_seq.to_i
75
+ start += (page.to_i - 1) * limit if page
76
+
77
+ offset(start).each do |message|
78
+ jobs << message
79
+ break if jobs.size >= limit
80
+ end
81
+
82
+ jobs
83
+ end
84
+
85
+ def message(seq)
86
+ Job.new(name, client.get_message(name, seq: seq, direct: true))
87
+ rescue NATS::JetStream::Error::NotFound
88
+ # nop, acked/nacked
89
+ end
90
+
91
+ def retry(seq)
92
+ job = message(seq)
93
+ return unless job
94
+
95
+ client.publish(job.x_subject, job.message.data)
96
+ delete(seq)
97
+ end
98
+
99
+ def delete(seq)
100
+ client.delete_message(name, seq)
101
+ end
102
+
103
+ private
104
+
105
+ def client
106
+ self.class.client
107
+ end
108
+ end
109
+ end
110
+ end
data/lib/cosmo/api.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/api/stream"
4
+ require "cosmo/api/counter"
5
+ require "cosmo/api/kv"
6
+ require "cosmo/api/stats"
7
+
8
+ module Cosmo
9
+ module API
10
+ end
11
+ end
data/lib/cosmo/cli.rb CHANGED
@@ -102,10 +102,12 @@ module Cosmo
102
102
  load_config(flags)
103
103
  boot_application
104
104
 
105
- Config[:streams].each do |name, config|
106
- Client.instance.stream_info(name)
107
- rescue NATS::JetStream::Error::NotFound
108
- Client.instance.create_stream(name, config)
105
+ Config[:setup]&.each_value do |configs|
106
+ configs.each do |name, config|
107
+ Client.instance.stream_info(name)
108
+ rescue NATS::JetStream::Error::NotFound
109
+ Client.instance.create_stream(name, config)
110
+ end
109
111
  end
110
112
 
111
113
  puts "Cosmo streams were created/updated"
data/lib/cosmo/client.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "nats/client"
4
+ require "cosmo/utils/overrides"
4
5
 
5
6
  module Cosmo
6
7
  class Client
@@ -11,7 +12,9 @@ module Cosmo
11
12
  attr_reader :nc, :js
12
13
 
13
14
  def initialize(nats_url: ENV.fetch("NATS_URL", "nats://localhost:4222"))
15
+ Logger.debug "Connecting to NATS server at #{nats_url}..."
14
16
  @nc = NATS.connect(nats_url)
17
+ Logger.debug "Connection established"
15
18
  @js = @nc.jetstream
16
19
  end
17
20
 
@@ -43,8 +46,38 @@ module Cosmo
43
46
  data["streams"].filter_map { _1.dig("config", "name") }
44
47
  end
45
48
 
46
- def get_message(name, seq)
47
- js.get_msg(name, seq: seq)
49
+ def list_consumers(stream_name)
50
+ response = nc.request("$JS.API.CONSUMER.LIST.#{stream_name}", "")
51
+ data = Utils::Json.parse(response.data, symbolize_names: false)
52
+ data["consumers"]
53
+ end
54
+
55
+ def consumer_info(stream_name, consumer_name)
56
+ js.consumer_info(stream_name, consumer_name)
57
+ end
58
+
59
+ def get_message(name, **options)
60
+ js.get_msg(name, **options)
61
+ end
62
+
63
+ def delete_message(name, seq)
64
+ response = nc.request("$JS.API.STREAM.MSG.DELETE.#{name}", JSON.dump({ seq: seq }))
65
+ Utils::Json.parse(response.data, symbolize_names: false)
66
+ end
67
+
68
+ def purge(stream_name, subject)
69
+ payload = subject ? Utils::Json.dump({ filter: subject }) : ""
70
+ response = @nc.request("$JS.API.STREAM.PURGE.#{stream_name}", payload)
71
+ result = Utils::Json.parse(response.data, default: {}, symbolize_names: false)
72
+ raise NATS::JetStream::Error, result.dig("error", "description") if result["error"]
73
+
74
+ result["purged"] # number of messages purged
75
+ end
76
+
77
+ def kv(name, **options)
78
+ js.key_value(name)
79
+ rescue NATS::KeyValue::BucketNotFoundError
80
+ js.create_key_value({ bucket: name }.merge(options))
48
81
  end
49
82
 
50
83
  def close
data/lib/cosmo/config.rb CHANGED
@@ -18,7 +18,7 @@ module Cosmo
18
18
  YAML.load_file(path, aliases: true).tap { normalize!(_1) }
19
19
  end
20
20
 
21
- def self.normalize!(config) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
21
+ def self.normalize!(config) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
22
22
  Utils::Hash.symbolize_keys!(config)
23
23
 
24
24
  config[:consumers]&.each_key do |name|
@@ -30,11 +30,13 @@ module Cosmo
30
30
  end
31
31
  end
32
32
 
33
- config[:streams]&.each_key do |name|
34
- c = config[:streams][name]
35
- c[:max_age] = c[:max_age].to_i * NANO if c[:max_age]
36
- c[:duplicate_window] = c[:duplicate_window].to_i * NANO if c[:duplicate_window]
37
- c[:subjects] = c[:subjects].map { |s| format(s, name: name) } if c[:subjects]
33
+ config[:setup]&.each_key do |type|
34
+ config[:setup][type]&.each_key do |name|
35
+ c = config[:setup][type][name]
36
+ c[:max_age] = c[:max_age].to_i * NANO if c[:max_age]
37
+ c[:duplicate_window] = c[:duplicate_window].to_i * NANO if c[:duplicate_window]
38
+ c[:subjects] = c[:subjects].map { |s| format(s, name: name) } if c[:subjects]
39
+ end
38
40
  end
39
41
  end
40
42
 
@@ -27,39 +27,40 @@ consumers:
27
27
  max_ack_pending: 100
28
28
  ack_wait: 10
29
29
 
30
- streams:
31
- critical:
32
- <<: &config
33
- storage: file
34
- retention: workqueue
35
- duplicate_window: 120 # 2m
36
- discard: old
37
- allow_direct: true
38
- subjects:
39
- - jobs.%{name}.>
40
- description: Very critical priority jobs
41
- high:
42
- <<: *config
43
- description: Higher priority jobs
44
- default:
45
- <<: *config
46
- description: Default priority jobs
47
- low:
48
- <<: *config
49
- description: Lower priority jobs
50
- scheduled:
51
- <<: *config
52
- description: Scheduled jobs
53
- dead:
54
- <<: *config
55
- retention: limits
56
- max_msgs: 10000
57
- max_age: 604800 # 7d
58
- description: Broken jobs (DLQ)
30
+ setup:
31
+ jobs:
32
+ critical:
33
+ <<: &config
34
+ storage: file
35
+ retention: workqueue
36
+ duplicate_window: 120 # 2m
37
+ discard: old
38
+ allow_direct: true
39
+ subjects:
40
+ - jobs.%{name}.>
41
+ description: Very critical priority jobs
42
+ high:
43
+ <<: *config
44
+ description: Higher priority jobs
45
+ default:
46
+ <<: *config
47
+ description: Default priority jobs
48
+ low:
49
+ <<: *config
50
+ description: Lower priority jobs
51
+ scheduled:
52
+ <<: *config
53
+ description: Scheduled jobs
54
+ dead:
55
+ <<: *config
56
+ retention: limits
57
+ max_msgs: 10000
58
+ max_age: 604800 # 7d
59
+ description: Broken jobs (DLQ)
59
60
 
60
61
  development:
61
62
  verbose: false
62
- concurrency: 1
63
+ concurrency: *concurrency
63
64
 
64
65
  staging:
65
66
  verbose: true