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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c001e33a420b73f46eebfe6ecffca697ef7d67f0436a2b70a88ed6207b3846a5
4
- data.tar.gz: 2fd541b16a24b5a461ea60078be20d92a0c815d0a09996fc9e419f958fce31ea
3
+ metadata.gz: f850638b66506538b131180c03b64a13e31cd86f924503703812da49b6ba3d20
4
+ data.tar.gz: 0dfdab08193600f3bca2c32558ffb016a38657f9488789770eea5e60612281e9
5
5
  SHA512:
6
- metadata.gz: b401aaac7b86f8d08167d256814bed8cd8332aa1f9d82e97548f89d5f3770f95d90a8c36dc711eb0798fbc21ec654d21f629879e3fb56e8d3ecc4c943411be24
7
- data.tar.gz: 8ff68d161c0f1f15dabddab8e36ad37363456126c5332bdfe636620e68c91510d690048585fa2064538e77ef3be6c1f892be4f57878de844d9f32d4a46815da3
6
+ metadata.gz: 56f46ac8a8c1409b1583033ad8b5cf9e513652fae2643b07344aa8f8bf1cca80f948463af92e2a989ec9679cc830ac05a6b873b1f0c9b5e10a1e6ef5e8399ad0
7
+ data.tar.gz: '086ae5c5b26471417e54c37d2f782c0c103340953e9a27ca8c02131478122f01ede30f2f8ab5a0b29c81ce7b4ea07a5c8de8e7f51957e4d9d5dfd5d670a0ff08'
data/README.md CHANGED
@@ -95,62 +95,69 @@ gem "cosmonats"
95
95
 
96
96
  **Requirements:** Ruby 3.1.0+, NATS Server ([installation guide](https://docs.nats.io/running-a-nats-service/introduction/installation))
97
97
 
98
+ Add these lines to config/routes.rb:
99
+ ```ruby
100
+ require "cosmo/web"
101
+
102
+ Rails.application.routes.draw do
103
+ mount Cosmo::Web => "/cosmo" # access web UI at http://localhost:3000/cosmo
104
+ ...
105
+ end
106
+ ```
107
+
98
108
 
99
109
  ## 🚀 Quick Start
100
110
 
101
- ### 1. Create a Job
111
+ ### 1. Create `config/cosmo.yml` and run `bundle exec cosmo -S` to create streams in NATS:
112
+
113
+ ```yaml
114
+ concurrency: 5
115
+ max_retries: 3
116
+
117
+ consumers:
118
+ jobs:
119
+ default:
120
+ ack_policy: explicit
121
+ max_deliver: 10
122
+ max_ack_pending: 10
123
+ ack_wait: 15
124
+ subject: jobs.%{name}.>
125
+
126
+ setup:
127
+ jobs:
128
+ default:
129
+ storage: file
130
+ retention: workqueue
131
+ subjects: ["jobs.%{name}.>"]
132
+ allow_direct: true
133
+ ```
134
+
135
+ ### 2. Create a Job in app/workers
102
136
 
103
137
  ```ruby
104
138
  class SendEmailJob
105
139
  include Cosmo::Job
106
140
 
107
- # configure job options (optional)
108
141
  options stream: :default, retry: 3, dead: true
109
142
 
110
143
  def perform(user_id, email_type)
111
- user = User.find(user_id)
112
- UserMailer.send(email_type, user).deliver_now
144
+ # Pretend to send email to user: UserMailer.send(email_type, user_id).deliver_now
145
+ sleep 0.5 # Simulate work
146
+ puts "#{user_id}, #{email_type}"
113
147
  end
114
148
  end
115
149
  ```
116
150
 
117
- ### 2. Enqueue Jobs
151
+ ### 3. Enqueue Jobs
118
152
 
119
153
  ```ruby
120
- SendEmailJob.perform_async(123, 'welcome') # Immediately
121
- SendEmailJob.perform_in(1.hour, 123, 'reminder') # Delayed
122
- SendEmailJob.perform_at(1.day.from_now, 123, 'test') # Scheduled
123
- ```
124
-
125
- ### 3. Configure (config/cosmo.yml)
126
-
127
- ```yaml
128
- concurrency: 10
129
- max_retries: 3
130
-
131
- consumers:
132
- jobs:
133
- default:
134
- ack_policy: explicit
135
- max_deliver: 3
136
- max_ack_pending: 3
137
- ack_wait: 60
138
-
139
- streams:
140
- default:
141
- storage: file
142
- retention: workqueue
143
- subjects: ["jobs.default.>"]
154
+ 10.times { |i| SendEmailJob.perform_async(i, "welcome") }
144
155
  ```
145
156
 
146
- ### 4. Setup & Run
157
+ ### 4. Run
147
158
 
148
159
  ```bash
149
- # Setup streams
150
- cosmo -C config/cosmo.yml --setup
151
-
152
- # Start processing
153
- cosmo -C config/cosmo.yml -c 10 -r ./app/jobs jobs
160
+ bundle exec cosmo jobs
154
161
  ```
155
162
 
156
163
 
@@ -182,7 +189,7 @@ end
182
189
  # Usage
183
190
  ReportJob.perform_async(42) # Enqueue now
184
191
  ReportJob.perform_in(30.minutes, 42) # Delayed
185
- ReportJob.perform_at(Time.parse('2026-01-25 10:00'), 42) # Scheduled
192
+ ReportJob.perform_at(Time.parse("2026-01-25 10:00"), 42) # Scheduled
186
193
  ```
187
194
 
188
195
  ### Streams
@@ -221,8 +228,8 @@ end
221
228
 
222
229
  # Publishing
223
230
  ClicksProcessor.publish(
224
- { user_id: 123, page: '/home' },
225
- subject: 'events.clicks.homepage'
231
+ { user_id: 123, page: "/home" },
232
+ subject: "events.clicks.homepage"
226
233
  )
227
234
 
228
235
  # Message acknowledgment strategies
@@ -235,31 +242,86 @@ message.term # Permanent failure, no retry
235
242
 
236
243
  **File-based (config/cosmo.yml):**
237
244
  ```yaml
238
- timeout: 25 # Shutdown timeout in seconds
239
- concurrency: 10 # Number of worker threads
240
- max_retries: 3 # Default max retries
245
+ timeout: 25 # Shutdown timeout in seconds
246
+ concurrency: &concurrency 1 # Number of worker threads
247
+ max_retries: &max_retries 3 # Default max retries
248
+
249
+ stream_config: &stream_config
250
+ storage: file # storage type (file or memory)
251
+ retention: workqueue # retention policy (limits, interest, workqueue)
252
+ duplicate_window: 120 # time window for duplicate message detection in seconds
253
+ discard: old # discard new messages when stream is full (discard new or old)
254
+ allow_direct: true # allow direct messages to stream, required for web UI
255
+ subjects:
256
+ - jobs.%{name}.> # subject pattern for stream, %{name} will be replaced with stream name
257
+
258
+ consumer_config: &consumer_config
259
+ ack_policy: explicit # ack policy (explicit, none, all), each individual message must be acknowledged
260
+ max_deliver: 10 # maximum number of times a message will be delivered before it's considered failed
261
+ max_ack_pending: 20 # maximum number of messages with pending ack for this consumer
262
+ ack_wait: 60 # time in seconds to wait for an ack before redelivering the message
263
+ subject: jobs.%{name}.> # subject pattern for consumer, %{name} will be replaced with stream name
241
264
 
242
265
  consumers:
243
- streams:
244
- - class: MyStream
245
- batch_size: 50
246
- consumer:
247
- ack_policy: explicit
248
- max_deliver: 3
249
- subjects: ["events.>"]
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.>"]
266
+ jobs:
267
+ critical:
268
+ <<: *consumer_config
269
+ priority: 50
270
+ high:
271
+ <<: *consumer_config
272
+ priority: 30
273
+ default:
274
+ <<: *consumer_config
275
+ priority: 15
276
+ low:
277
+ <<: *consumer_config
278
+ priority: 5
279
+ scheduled:
280
+ <<: *consumer_config
281
+ max_deliver: 1
282
+ max_ack_pending: 100
283
+ ack_wait: 10
284
+
285
+ setup:
286
+ jobs:
287
+ critical:
288
+ <<: *stream_config
289
+ description: Very critical priority jobs
290
+ high:
291
+ <<: *stream_config
292
+ description: Higher priority jobs
293
+ default:
294
+ <<: *stream_config
295
+ description: Default priority jobs
296
+ low:
297
+ <<: *stream_config
298
+ description: Lower priority jobs
299
+ scheduled:
300
+ <<: *stream_config
301
+ description: Scheduled jobs
302
+ dead:
303
+ <<: *stream_config
304
+ retention: limits
305
+ max_msgs: 10000
306
+ max_age: 604800 # 7d
307
+ description: Broken jobs (DLQ)
308
+
309
+ development:
310
+ verbose: false
311
+ concurrency: *concurrency
312
+
313
+ staging:
314
+ verbose: true
315
+ concurrency: 3
316
+
317
+ production:
318
+ concurrency: 3
257
319
  ```
258
320
 
259
321
  **Programmatic:**
260
322
  ```ruby
261
323
  Cosmo::Config.set(:concurrency, 20)
262
- Cosmo::Config.set(:streams, :custom, { storage: 'file', subjects: ['custom.>'] })
324
+ Cosmo::Config.set(:setup, :streams, :custom, { storage: "file", subjects: ["custom.>"] })
263
325
  ```
264
326
 
265
327
  **Environment variables:**
@@ -327,10 +389,10 @@ end
327
389
  **Testing:**
328
390
  ```ruby
329
391
  # Synchronous execution
330
- SendEmailJob.perform_sync(123, 'test')
392
+ SendEmailJob.perform_sync(123, "test")
331
393
 
332
394
  # Test job creation
333
- jid = SendEmailJob.perform_async(123, 'welcome')
395
+ jid = SendEmailJob.perform_async(123, "welcome")
334
396
  assert_kind_of String, jid
335
397
  ```
336
398
 
@@ -437,7 +499,7 @@ sudo systemctl status cosmo
437
499
  **Stream Metrics:**
438
500
  ```ruby
439
501
  client = Cosmo::Client.instance
440
- info = client.stream_info('default')
502
+ info = client.stream_info("default")
441
503
 
442
504
  info.state.messages # Total messages
443
505
  info.state.bytes # Total bytes
@@ -464,8 +526,8 @@ class EmailJob
464
526
  end
465
527
  end
466
528
 
467
- EmailJob.perform_async(123, 'welcome')
468
- EmailJob.perform_in(1.day, 123, 'followup')
529
+ EmailJob.perform_async(123, "welcome")
530
+ EmailJob.perform_in(1.day, 123, "followup")
469
531
  ```
470
532
 
471
533
  **Image Processing Pipeline:**
@@ -474,12 +536,12 @@ class ImageProcessor
474
536
  include Cosmo::Stream
475
537
  options(
476
538
  stream: :images,
477
- consumer: { subjects: ['images.uploaded.>'] }
539
+ consumer: { subjects: ["images.uploaded.>"] }
478
540
  )
479
541
 
480
542
  def process_one
481
- processed = ImageService.process(message.data['url'])
482
- publish(processed, subject: 'images.processed.optimized')
543
+ processed = ImageService.process(message.data["url"])
544
+ publish(processed, subject: "images.processed.optimized")
483
545
  message.ack
484
546
  rescue => e
485
547
  logger.error "Processing failed: #{e.message}"
@@ -487,18 +549,18 @@ class ImageProcessor
487
549
  end
488
550
  end
489
551
 
490
- ImageProcessor.publish({ url: 'https://example.com/image.jpg' }, subject: 'images.uploaded.user')
552
+ ImageProcessor.publish({ url: "https://example.com/image.jpg" }, subject: "images.uploaded.user")
491
553
  ```
492
554
 
493
555
  **Real-Time Analytics:**
494
556
  ```ruby
495
557
  class AnalyticsAggregator
496
558
  include Cosmo::Stream
497
- options batch_size: 1000, consumer: { subjects: ['events.*.>'] }
559
+ options batch_size: 1000, consumer: { subjects: ["events.*.>"] }
498
560
 
499
561
  def process(messages)
500
562
  events = messages.map(&:data)
501
- aggregates = events.group_by { |e| e['type'] }.transform_values(&:count)
563
+ aggregates = events.group_by { |e| e["type"] }.transform_values(&:count)
502
564
  Analytics.bulk_insert(aggregates)
503
565
  messages.each(&:ack)
504
566
  end
@@ -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, NATS::JetStream::Error::NotFound
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