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.
- checksums.yaml +4 -4
- data/README.md +129 -67
- data/lib/cosmo/api/busy.rb +66 -0
- data/lib/cosmo/api/counter.rb +70 -0
- data/lib/cosmo/api/job.rb +46 -0
- data/lib/cosmo/api/kv.rb +63 -0
- data/lib/cosmo/api/stats.rb +44 -0
- data/lib/cosmo/api/stream.rb +123 -0
- data/lib/cosmo/api.rb +11 -0
- data/lib/cosmo/cli.rb +8 -5
- data/lib/cosmo/client.rb +58 -3
- data/lib/cosmo/config.rb +13 -38
- data/lib/cosmo/engine.rb +1 -1
- data/lib/cosmo/job/processor.rb +66 -57
- data/lib/cosmo/job.rb +1 -1
- data/lib/cosmo/logger.rb +8 -1
- data/lib/cosmo/processor.rb +110 -2
- data/lib/cosmo/stream/processor.rb +23 -59
- data/lib/cosmo/stream.rb +2 -2
- data/lib/cosmo/utils/hash.rb +3 -27
- data/lib/cosmo/utils/overrides.rb +15 -0
- data/lib/cosmo/utils/ttl_cache.rb +44 -0
- data/lib/cosmo/utils/warnings.rb +17 -0
- data/lib/cosmo/utils.rb +15 -0
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +477 -0
- data/lib/cosmo/web/assets/htmx.2.0.8.min.js.gz +0 -0
- data/lib/cosmo/web/context.rb +28 -0
- data/lib/cosmo/web/controllers/actions.rb +16 -0
- data/lib/cosmo/web/controllers/application.rb +43 -0
- data/lib/cosmo/web/controllers/jobs.rb +97 -0
- data/lib/cosmo/web/controllers/streams.rb +70 -0
- data/lib/cosmo/web/helpers/application.rb +87 -0
- data/lib/cosmo/web/renderer.rb +58 -0
- data/lib/cosmo/web/views/actions/index.erb +7 -0
- data/lib/cosmo/web/views/jobs/_busy.erb +50 -0
- data/lib/cosmo/web/views/jobs/_dead.erb +65 -0
- data/lib/cosmo/web/views/jobs/_enqueued.erb +60 -0
- data/lib/cosmo/web/views/jobs/_scheduled.erb +49 -0
- data/lib/cosmo/web/views/jobs/_stats.erb +69 -0
- data/lib/cosmo/web/views/jobs/busy.erb +16 -0
- data/lib/cosmo/web/views/jobs/dead.erb +17 -0
- data/lib/cosmo/web/views/jobs/enqueued.erb +16 -0
- data/lib/cosmo/web/views/jobs/index.erb +12 -0
- data/lib/cosmo/web/views/jobs/scheduled.erb +17 -0
- data/lib/cosmo/web/views/layout.erb +33 -0
- data/lib/cosmo/web/views/streams/_info.erb +92 -0
- data/lib/cosmo/web/views/streams/_pause_banner.erb +17 -0
- data/lib/cosmo/web/views/streams/_stream_row.erb +42 -0
- data/lib/cosmo/web/views/streams/_table.erb +25 -0
- data/lib/cosmo/web/views/streams/index.erb +11 -0
- data/lib/cosmo/web/views/streams/info.erb +11 -0
- data/lib/cosmo/web.rb +68 -0
- data/lib/cosmo.rb +2 -7
- data/sig/cosmo/api/busy.rbs +35 -0
- data/sig/cosmo/api/counter.rbs +34 -0
- data/sig/cosmo/api/job.rbs +31 -0
- data/sig/cosmo/api/kv.rbs +30 -0
- data/sig/cosmo/api/stats.rbs +21 -0
- data/sig/cosmo/api/stream.rbs +50 -0
- data/sig/cosmo/client.rbs +21 -3
- data/sig/cosmo/config.rbs +3 -15
- data/sig/cosmo/job/processor.rbs +16 -8
- data/sig/cosmo/processor.rbs +26 -0
- data/sig/cosmo/stream/processor.rbs +4 -10
- data/sig/cosmo/utils/hash.rbs +0 -8
- data/sig/cosmo/utils/ttl_cache.rbs +20 -0
- metadata +62 -3
- data/lib/cosmo/defaults.yml +0 -69
data/lib/cosmo/processor.rb
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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.
|
|
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
|
|
105
|
-
timeout =
|
|
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.
|
|
32
|
-
Config.
|
|
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
|
data/lib/cosmo/utils/hash.rb
CHANGED
|
@@ -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
|
|
53
|
-
def
|
|
54
|
-
|
|
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
|
data/lib/cosmo/utils.rb
ADDED
|
@@ -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