cosmonats 0.1.4 → 0.3.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +129 -67
  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 +123 -0
  9. data/lib/cosmo/api.rb +11 -0
  10. data/lib/cosmo/cli.rb +8 -5
  11. data/lib/cosmo/client.rb +58 -3
  12. data/lib/cosmo/config.rb +13 -38
  13. data/lib/cosmo/engine.rb +1 -1
  14. data/lib/cosmo/job/processor.rb +66 -57
  15. data/lib/cosmo/job.rb +1 -1
  16. data/lib/cosmo/logger.rb +8 -1
  17. data/lib/cosmo/processor.rb +110 -2
  18. data/lib/cosmo/stream/processor.rb +23 -59
  19. data/lib/cosmo/stream.rb +2 -2
  20. data/lib/cosmo/utils/hash.rb +3 -27
  21. data/lib/cosmo/utils/overrides.rb +15 -0
  22. data/lib/cosmo/utils/ttl_cache.rb +44 -0
  23. data/lib/cosmo/utils/warnings.rb +17 -0
  24. data/lib/cosmo/utils.rb +15 -0
  25. data/lib/cosmo/version.rb +1 -1
  26. data/lib/cosmo/web/assets/app.css +477 -0
  27. data/lib/cosmo/web/assets/htmx.2.0.8.min.js.gz +0 -0
  28. data/lib/cosmo/web/context.rb +28 -0
  29. data/lib/cosmo/web/controllers/actions.rb +16 -0
  30. data/lib/cosmo/web/controllers/application.rb +43 -0
  31. data/lib/cosmo/web/controllers/jobs.rb +97 -0
  32. data/lib/cosmo/web/controllers/streams.rb +70 -0
  33. data/lib/cosmo/web/helpers/application.rb +87 -0
  34. data/lib/cosmo/web/renderer.rb +58 -0
  35. data/lib/cosmo/web/views/actions/index.erb +7 -0
  36. data/lib/cosmo/web/views/jobs/_busy.erb +50 -0
  37. data/lib/cosmo/web/views/jobs/_dead.erb +65 -0
  38. data/lib/cosmo/web/views/jobs/_enqueued.erb +60 -0
  39. data/lib/cosmo/web/views/jobs/_scheduled.erb +49 -0
  40. data/lib/cosmo/web/views/jobs/_stats.erb +69 -0
  41. data/lib/cosmo/web/views/jobs/busy.erb +16 -0
  42. data/lib/cosmo/web/views/jobs/dead.erb +17 -0
  43. data/lib/cosmo/web/views/jobs/enqueued.erb +16 -0
  44. data/lib/cosmo/web/views/jobs/index.erb +12 -0
  45. data/lib/cosmo/web/views/jobs/scheduled.erb +17 -0
  46. data/lib/cosmo/web/views/layout.erb +33 -0
  47. data/lib/cosmo/web/views/streams/_info.erb +92 -0
  48. data/lib/cosmo/web/views/streams/_pause_banner.erb +17 -0
  49. data/lib/cosmo/web/views/streams/_stream_row.erb +42 -0
  50. data/lib/cosmo/web/views/streams/_table.erb +25 -0
  51. data/lib/cosmo/web/views/streams/index.erb +11 -0
  52. data/lib/cosmo/web/views/streams/info.erb +11 -0
  53. data/lib/cosmo/web.rb +68 -0
  54. data/lib/cosmo.rb +2 -7
  55. data/sig/cosmo/api/busy.rbs +35 -0
  56. data/sig/cosmo/api/counter.rbs +34 -0
  57. data/sig/cosmo/api/job.rbs +31 -0
  58. data/sig/cosmo/api/kv.rbs +30 -0
  59. data/sig/cosmo/api/stats.rbs +21 -0
  60. data/sig/cosmo/api/stream.rbs +50 -0
  61. data/sig/cosmo/client.rbs +21 -3
  62. data/sig/cosmo/config.rbs +3 -15
  63. data/sig/cosmo/job/processor.rbs +16 -8
  64. data/sig/cosmo/processor.rbs +26 -0
  65. data/sig/cosmo/stream/processor.rbs +4 -10
  66. data/sig/cosmo/utils/hash.rbs +0 -8
  67. data/sig/cosmo/utils/ttl_cache.rbs +20 -0
  68. metadata +62 -3
  69. data/lib/cosmo/defaults.yml +0 -69
@@ -1,16 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cosmo
4
- class Processor
4
+ class Processor # rubocop:disable Metrics/ClassLength
5
+ STREAM_PAUSED_RECHECK_TTL = 5.0 # Seconds a stream's paused state is cached before re-checking (override via COSMO_STREAM_PAUSED_RECHECK_TTL)
6
+ STREAMS_PAUSED_IDLE_SLEEP = 1.0 # Seconds to sleep when every stream is paused, preventing a tight CPU spin (override via COSMO_STREAMS_PAUSED_IDLE_SLEEP)
7
+ STREAM_EMPTY_BACKOFF_MAX = 5.0 # Max seconds to sleep between empty fetches (override via COSMO_STREAM_EMPTY_BACKOFF_MAX)
8
+
5
9
  def self.run(...)
6
10
  new(...).tap(&:run)
7
11
  end
8
12
 
13
+ attr_reader :consumers
14
+
9
15
  def initialize(pool, running, options)
10
16
  @pool = pool
11
17
  @running = running
12
18
  @options = options
19
+ @threads = []
13
20
  @consumers = []
21
+ @cache = Utils::TTLCache.new
14
22
  end
15
23
 
16
24
  def run
@@ -21,9 +29,95 @@ module Cosmo
21
29
  run_loop
22
30
  end
23
31
 
32
+ def stop(timeout = Config[:timeout])
33
+ @running.make_false
34
+ @pool.shutdown
35
+ @consumers.each { |(s, _)| s.unsubscribe rescue nil }
36
+ @pool.wait_for_termination(timeout)
37
+ @threads.compact.each { _1.join(timeout) || _1.kill }
38
+ @consumers.clear
39
+ @threads.clear
40
+ end
41
+
24
42
  private
25
43
 
26
44
  def run_loop
45
+ @threads << Thread.new { work_loop }
46
+ @threads << Thread.new { schedule_loop } if scheduler?
47
+ end
48
+
49
+ def work_loop # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
50
+ shutdown = false
51
+
52
+ while running?
53
+ break if shutdown
54
+
55
+ all_empty = true # every stream is empty
56
+ all_paused = true # every stream is paused
57
+ consumers.each do |(subscription, config, processor)| # rubocop:disable Metrics/BlockLength
58
+ break unless running?
59
+
60
+ stream_name = config[:stream].to_s
61
+ ttl = ENV.fetch("COSMO_STREAM_PAUSED_RECHECK_TTL", STREAM_PAUSED_RECHECK_TTL).to_f
62
+ if @cache.fetch(stream_name, ttl:) { API::Stream.new(stream_name).paused? }
63
+ Logger.debug "stream #{stream_name} is paused, skipping fetch"
64
+ next
65
+ end
66
+ all_paused = false
67
+
68
+ _, skip_t = consumer_state[stream_name]
69
+ if skip_t && Time.now < skip_t
70
+ Logger.debug "stream #{stream_name} is empty, backing off"
71
+ next
72
+ end
73
+ all_empty = false
74
+
75
+ begin
76
+ @pool.post do
77
+ # Re-check, after possibly being blocked on post to thread pool
78
+ _, skip_t = consumer_state[stream_name]
79
+ next if skip_t && Time.now < skip_t
80
+
81
+ timeout = fetch_timeout(config)
82
+ Logger.debug "fetching #{fetch_subjects(config).inspect}, timeout=#{timeout}"
83
+ messages = lock(stream_name) { fetch(subscription, batch_size: config[:batch_size], timeout:) }
84
+ Logger.debug "fetched (#{messages&.size.to_i}) messages"
85
+ if messages&.any?
86
+ consumer_state.delete(stream_name)
87
+ process(messages, processor)
88
+ else
89
+ max_backoff = ENV.fetch("COSMO_STREAM_EMPTY_BACKOFF_MAX", STREAM_EMPTY_BACKOFF_MAX).to_f
90
+ consumer_state.compute(stream_name) do |current|
91
+ count = (current&.first || 0) + 1
92
+ backoff = [timeout * (2**(count - 1)), max_backoff].min
93
+ [count, Time.now + backoff]
94
+ end
95
+ end
96
+ end
97
+ rescue Concurrent::RejectedExecutionError
98
+ shutdown = true
99
+ break # pool doesn't accept new jobs, we are shutting down
100
+ end
101
+ end
102
+
103
+ break unless running?
104
+
105
+ if all_paused
106
+ period = ENV.fetch("COSMO_STREAMS_PAUSED_IDLE_SLEEP", STREAMS_PAUSED_IDLE_SLEEP).to_f
107
+ Logger.debug "all streams paused, sleep=#{period}"
108
+ sleep(period)
109
+ elsif all_empty
110
+ next_wake = consumer_state.values.filter_map { |_, t| t }.min
111
+ next unless next_wake # entry was deleted concurrently (messages arrived), re-loop immediately
112
+
113
+ remaining = [next_wake - Time.now, 0.01].max
114
+ Logger.debug "all streams empty, sleep=#{remaining}"
115
+ sleep(remaining)
116
+ end
117
+ end
118
+ end
119
+
120
+ def schedule_loop
27
121
  raise NotImplementedError
28
122
  end
29
123
 
@@ -39,16 +133,21 @@ module Cosmo
39
133
  @running.true?
40
134
  end
41
135
 
136
+ def scheduler?
137
+ false
138
+ end
139
+
42
140
  def fetch(subscription, batch_size:, timeout:)
43
141
  subscription.fetch(batch_size, timeout:)
44
142
  rescue NATS::Timeout
45
143
  # No messages, continue
46
144
  rescue StandardError => e
47
145
  Logger.error "Snap! Error just happened"
48
- Logger.error "#{e.class}: #{e.message}"
146
+ Logger.error "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
49
147
 
50
148
  backoff = ENV.fetch("COSMO_STREAMS_FETCH_BACKOFF", 5).to_f
51
149
  sleep([timeout, backoff].max) # backoff before retry
150
+ nil
52
151
  end
53
152
 
54
153
  def client
@@ -58,5 +157,14 @@ module Cosmo
58
157
  def stopwatch
59
158
  Utils::Stopwatch.new
60
159
  end
160
+
161
+ def lock(stream_name, &)
162
+ @locks ||= Hash.new { |h, k| h[k] = Mutex.new }
163
+ @locks[stream_name].synchronize(&)
164
+ end
165
+
166
+ def consumer_state
167
+ @consumer_state ||= Concurrent::Map.new
168
+ end
61
169
  end
62
170
  end
@@ -3,48 +3,18 @@
3
3
  module Cosmo
4
4
  module Stream
5
5
  class Processor < ::Cosmo::Processor
6
- def initialize(pool, running, options)
7
- super
8
- @configs = []
9
- end
10
-
11
6
  private
12
7
 
13
- def run_loop
14
- Thread.new { work_loop }
15
- end
16
-
17
8
  def setup
18
- setup_configs
19
- setup_consumers
20
- end
21
-
22
- def work_loop # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
23
- shutdown = false
24
-
25
- while running?
26
- break if shutdown
27
-
28
- @consumers.each do |(subscription, config, processor)|
29
- break unless running?
30
-
31
- begin
32
- @pool.post do
33
- timeout = convert_timeout(config[:fetch_timeout])
34
- Logger.debug "fetching #{config.dig(:consumer, :subjects).inspect}, timeout=#{timeout}"
35
- messages = fetch(subscription, batch_size: config[:batch_size], timeout:)
36
- Logger.debug "fetched (#{messages&.size.to_i}) messages"
37
- process(messages, processor) if messages&.any?
38
- Logger.debug "processed (#{messages&.size.to_i}) messages"
39
- end
40
- rescue Concurrent::RejectedExecutionError
41
- shutdown = true
42
- break # pool doesn't accept new jobs, we are shutting down
43
- end
9
+ @configs ||= []
10
+ @configs = static_config + dynamic_config
44
11
 
45
- break unless running?
46
- end
12
+ if @options[:processors]
13
+ pattern = Regexp.new(@options[:processors].map { "\\b#{_1}\\b" }.join("|"))
14
+ @configs.select! { _1[:class].name.match?(pattern) }
47
15
  end
16
+
17
+ @configs.each { @consumers << subscribe(nil, _1) }
48
18
  end
49
19
 
50
20
  def process(messages, processor) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
@@ -70,25 +40,6 @@ module Cosmo
70
40
  raise
71
41
  end
72
42
 
73
- def setup_configs
74
- @configs = static_config + dynamic_config
75
- return unless @options[:processors]
76
-
77
- pattern = Regexp.new(@options[:processors].map { "\\b#{_1}\\b" }.join("|"))
78
- @configs.select! { _1[:class].name.match?(pattern) }
79
- end
80
-
81
- def setup_consumers
82
- @configs.each do |config|
83
- processor = config[:class].new
84
- subjects = config.dig(:consumer, :subjects)
85
- deliver_policy = Config.deliver_policy(config[:start_position])
86
- consumer_config, consumer_name = config.values_at(:consumer, :consumer_name)
87
- subscription = client.subscribe(subjects, consumer_name, consumer_config.merge(deliver_policy))
88
- @consumers << [subscription, config, processor]
89
- end
90
- end
91
-
92
43
  def static_config
93
44
  Config.dig(:consumers, :streams)&.filter_map do |config|
94
45
  next unless (klass = Utils::String.safe_constantize(config[:class]))
@@ -98,11 +49,24 @@ module Cosmo
98
49
  end
99
50
 
100
51
  def dynamic_config
101
- Config.system[:streams].map { _1.default_options.merge(class: _1) }
52
+ Config.internal[:streams].map { _1.default_options.merge(class: _1) }
53
+ end
54
+
55
+ def subscribe(_stream_name, config)
56
+ processor = config[:class].new
57
+ subjects = config.dig(:consumer, :subjects)
58
+ deliver_policy = Config.deliver_policy(config[:start_position])
59
+ consumer_config, consumer_name = config.values_at(:consumer, :consumer_name)
60
+ subscription = client.subscribe(subjects, consumer_name, consumer_config.merge(deliver_policy))
61
+ [subscription, config, processor]
62
+ end
63
+
64
+ def fetch_subjects(config)
65
+ config.dig(:consumer, :subjects)
102
66
  end
103
67
 
104
- def convert_timeout(value)
105
- timeout = value.to_f
68
+ def fetch_timeout(config)
69
+ timeout = config[:fetch_timeout].to_f
106
70
  if timeout <= 0
107
71
  Logger.warn "Ignoring `fetch_timeout: #{timeout}` (causes high CPU usage) with #{Data::DEFAULTS[:fetch_timeout]}s instead"
108
72
  timeout = Data::DEFAULTS[:fetch_timeout].to_f
data/lib/cosmo/stream.rb CHANGED
@@ -28,8 +28,8 @@ module Cosmo
28
28
  end
29
29
 
30
30
  def register # rubocop:disable Metrics/AbcSize
31
- Config.system[:streams] ||= []
32
- Config.system[:streams] << self
31
+ Config.internal[:streams] ||= []
32
+ Config.internal[:streams] << self
33
33
 
34
34
  # settings are inherited, don't try to modify them
35
35
  return if default_options != Data::DEFAULTS
@@ -23,22 +23,6 @@ module Cosmo
23
23
  end
24
24
  end
25
25
 
26
- # deep dup
27
- def dup(hash)
28
- Marshal.load(Marshal.dump(hash))
29
- end
30
-
31
- # dig keys
32
- def keys?(hash, *keys)
33
- keys.reduce(hash) do |base, key|
34
- return false if !base.is_a?(::Hash) || !base.key?(key)
35
-
36
- base[key]
37
- end
38
-
39
- true
40
- end
41
-
42
26
  # deep set
43
27
  def set(hash, *keys, value)
44
28
  last_key = keys.pop
@@ -49,17 +33,9 @@ module Cosmo
49
33
  target[last_key] = value
50
34
  end
51
35
 
52
- # deep merge
53
- def merge(hash1, hash2)
54
- return hash1 unless hash2
55
-
56
- hash1.merge(hash2) do |_key, old_val, new_val|
57
- if old_val.is_a?(::Hash) && new_val.is_a?(::Hash)
58
- merge(old_val, new_val)
59
- else
60
- new_val
61
- end
62
- end
36
+ # deep dup
37
+ def dup(hash)
38
+ Marshal.load(Marshal.dump(hash))
63
39
  end
64
40
  end
65
41
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ Cosmo::Utils::Warnings.silence do
4
+ members = NATS::JetStream::API::StreamConfig.members + [:allow_msg_counter]
5
+ NATS::JetStream::API::StreamConfig = Struct.new(*members, keyword_init: true) do
6
+ def initialize(opts = {})
7
+ rem = opts.keys - members
8
+ opts.delete_if { |k| rem.include?(k) }
9
+ super
10
+ end
11
+ end
12
+
13
+ members = NATS::JetStream::PubAck.members + [:val]
14
+ NATS::JetStream::PubAck = Struct.new(*members, keyword_init: true)
15
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module Utils
5
+ class TTLCache
6
+ def initialize
7
+ @store = {}
8
+ end
9
+
10
+ def set(key, value, ttl: nil)
11
+ @store[key] = [value, ttl ? Time.now + ttl : nil]
12
+ value
13
+ end
14
+
15
+ def get(key)
16
+ return unless key?(key)
17
+
18
+ @store[key].first
19
+ end
20
+
21
+ def fetch(key, ttl: nil)
22
+ return get(key) if key?(key)
23
+
24
+ result = yield
25
+ set(key, result, ttl: ttl)
26
+ result
27
+ end
28
+
29
+ private
30
+
31
+ def key?(key)
32
+ exists = @store.key?(key)
33
+ return false unless exists
34
+
35
+ _, ttl = @store[key]
36
+ return true unless ttl
37
+ return true if Time.now < ttl
38
+
39
+ @store.delete(key)
40
+ false
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module Utils
5
+ module Warnings
6
+ module_function
7
+
8
+ def silence
9
+ verbose = $VERBOSE
10
+ $VERBOSE = nil
11
+ yield
12
+ ensure
13
+ $VERBOSE = verbose
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/utils/hash"
4
+ require "cosmo/utils/json"
5
+ require "cosmo/utils/string"
6
+ require "cosmo/utils/signal"
7
+ require "cosmo/utils/warnings"
8
+ require "cosmo/utils/stopwatch"
9
+ require "cosmo/utils/thread_pool"
10
+ require "cosmo/utils/ttl_cache"
11
+
12
+ module Cosmo
13
+ module Utils
14
+ end
15
+ end
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.1.4"
4
+ VERSION = "0.3.0"
5
5
  end