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
data/README.md CHANGED
@@ -10,7 +10,7 @@ Background jobs + real-time event streaming for Ruby — unified, in one gem, ba
10
10
  [![Gem Version](https://badge.fury.io/rb/cosmonats.svg)](https://rubygems.org/gems/cosmonats)
11
11
  [![Downloads](https://img.shields.io/gem/dt/cosmonats.svg)](https://rubygems.org/gems/cosmonats)
12
12
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-red)](https://www.ruby-lang.org)
13
- [![License: LGPL v3](https://img.shields.io/badge/License-LGPL%20v3-blue.svg)](LICENSE.txt)
13
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.txt)
14
14
  [![Build Status](https://github.com/bitsbeam/cosmonats/actions/workflows/ci.yml/badge.svg)](https://github.com/bitsbeam/cosmonats/actions)
15
15
 
16
16
  *Battle-tested in production. Tens of millions of jobs processed and counting.*
@@ -77,6 +77,7 @@ bundle exec cosmo -C config/cosmo.yml -c 20 streams # Streams only
77
77
  - [Concurrency Limiting](#concurrency-limiting)
78
78
  - [Custom Serializers](#custom-serializers)
79
79
  - [Error Handling](#error-handling)
80
+ - [Middleware](#middleware)
80
81
  - [Testing](#testing)
81
82
  - [Integrations](#integrations)
82
83
  - [CLI Reference](#-cli-reference)
@@ -166,7 +167,20 @@ nothing else to run.
166
167
  gem "cosmonats"
167
168
  ```
168
169
 
169
- **Requirements:** Ruby ≥ 3.1, NATS Server ([install guide](https://docs.nats.io/running-a-nats-service/introduction/installation))
170
+ **Requirements:** Ruby ≥ 3.1, NATS Server ≥ 2.14 with JetStream enabled ([install guide](https://docs.nats.io/running-a-nats-service/introduction/installation))
171
+
172
+ **Optional gems** — not installed with `cosmonats`, add them only for the features you use:
173
+
174
+ | Feature | Gems |
175
+ |-----------------------------------------------|-------------------|
176
+ | Monitoring UI (`Cosmo::Web`) | `rack` |
177
+ | Health check HTTP server (`/health`, `/ping`) | `rack`, `webrick` |
178
+
179
+ ```ruby
180
+ # Gemfile
181
+ gem "rack", "~> 3.0"
182
+ gem "webrick", "~> 1.9"
183
+ ```
170
184
 
171
185
  Spin up NATS instantly with Docker — one command, that's it:
172
186
  ```bash
@@ -195,33 +209,39 @@ mount Cosmo::Web => "/cosmo"
195
209
  map "/cosmo" { run Cosmo::Web }
196
210
  ```
197
211
 
212
+ > [!WARNING]
213
+ > `Cosmo::Web` ships with **no authentication and no CSRF protection**, yet it exposes destructive actions
214
+ > (retrying and deleting jobs, pausing streams, running and deleting crons). Never expose it unprotected.
198
215
 
199
- ## 🚀 Quick Start
216
+ ```ruby
217
+ # Rails, with Devise
218
+ authenticate :user, ->(user) { user.admin? } do
219
+ mount Cosmo::Web => "/cosmo"
220
+ end
200
221
 
201
- ### 1. Create `config/cosmo.yml`
222
+ # Any Rack app (config.ru), with HTTP Basic auth
223
+ map "/cosmo" do
224
+ use Rack::Auth::Basic, "Cosmo" do |user, password|
225
+ Rack::Utils.secure_compare(user, ENV.fetch("COSMO_WEB_USER")) &
226
+ Rack::Utils.secure_compare(password, ENV.fetch("COSMO_WEB_PASSWORD"))
227
+ end
228
+ run Cosmo::Web
229
+ end
230
+ ```
202
231
 
203
- ```yaml
204
- concurrency: 5 # Number of worker threads
205
232
 
206
- consumers: # Declare consumer groups for streams, things that pull messages and process them
207
- jobs: # Consumer configs for jobs (or streams)
208
- default: # Stream name
209
- ack_policy: explicit # Acknowledgment required for each message can be explicit, none, or all
210
- max_deliver: 30 # Max retry attempts before sending to a dead stream. Safety ceiling only, keep it above every job class's own retry
211
- max_ack_pending: 10 # Max messages waiting for ack, if exceeded, the server will stop delivering new messages until some are acked
212
- ack_wait: 15 # Seconds to wait for ack before redelivering
213
- subject: jobs.%{name}.> # Subject pattern for this consumer, %{name} replaced with stream name, becomes `jobs.default.>`
233
+ ## 🚀 Quick Start
214
234
 
215
- setup: # Initial stream creation only `cosmo -S`
216
- jobs: # Stream configs for jobs (or streams)
217
- default: # Stream name
218
- storage: file # Storage type (file or memory)
219
- retention: workqueue # Retention policy (limits, interest, workqueue). workqueue - deletes acked/nacked, limits - append only
220
- subjects: ["jobs.%{name}.>"] # Subject pattern for this stream, %{name} replaced with stream name
221
- allow_direct: true # Allow direct messages to stream (required for web UI)
235
+ ### 1. Configure (optional)
236
+
237
+ Nothing is required: without `config/cosmo.yml`, Cosmo runs one `default` job stream plus its own service streams.
238
+ To change anything, write Cosmo's defaults into your project and edit them:
239
+
240
+ ```bash
241
+ bundle exec cosmo --init # writes config/cosmo.yml
222
242
  ```
223
243
 
224
- ### 2. Create streams in NATS (one-time), grabs config from setup section of `config/cosmo.yml`
244
+ ### 2. Create streams in NATS (one-time, idempotent)
225
245
 
226
246
  ```bash
227
247
  bundle exec cosmo -S
@@ -280,6 +300,16 @@ ReportJob.perform_at(Time.parse("2026-01-25 10:00"), 42) # Scheduled
280
300
  ReportJob.perform_sync(42) # Inline, no NATS (great for tests)
281
301
  ```
282
302
 
303
+ Inside `perform`, the job instance knows about the message it came from:
304
+
305
+ | Accessor | Value |
306
+ |----------------|-----------------------------------------------------------------------------------------|
307
+ | `jid` | Job id, returned by `perform_async` |
308
+ | `attempt` | 1-based delivery attempt |
309
+ | `enqueued_at` | `Time` the job was enqueued; for a cron job, when NATS fired the schedule |
310
+ | `scheduled_by` | Subject of the cron schedule that fired the job, `nil` for jobs enqueued any other way |
311
+ | `batch_id` | Id of the [batch](#batches) the job belongs to, `nil` outside a batch |
312
+
283
313
  ### Streams
284
314
 
285
315
  ```ruby
@@ -325,97 +355,87 @@ message.term # Permanent failure, no retry
325
355
  **NATS subjects** follow a dot-separated hierarchy (`events.clicks.homepage`).
326
356
  The `>` wildcard matches everything after that prefix. Think of subjects as topic names — flexible routing with no extra configuration.
327
357
 
328
- **Full `config/cosmo.yml` example:**
358
+ **`config/cosmo.yml` is optional.** Cosmo ships its defaults in
359
+ [`lib/cosmo/config/cosmo.yml`](lib/cosmo/config/cosmo.yml) (`cosmo --init` copies it into your project), and your
360
+ file is deep-merged over them: keep only what you change. The one list that is not merged is your job streams —
361
+ listing any under `setup.jobs` replaces the built-in `default` stream and its consumer:
329
362
  ```yaml
330
- timeout: 25 # Shutdown timeout in seconds
331
- concurrency: &concurrency 1 # Number of worker threads
332
- max_retries: &max_retries 3 # Default max retries
333
- batch_expiry: 259200 # Seconds before a Batch's tracking data expires (default: 3 days)
363
+ concurrency: 10
334
364
 
335
365
  stream_config: &stream_config
336
- storage: file # storage type (file or memory)
337
- retention: workqueue # retention policy (limits, interest, workqueue)
338
- duplicate_window: 120 # time window for duplicate message detection in seconds
339
- discard: old # discard new messages when stream is full (discard new or old)
340
- allow_direct: true # allow direct messages to stream, required for web UI
341
- subjects:
342
- - jobs.%{name}.> # subject pattern for stream, %{name} will be replaced with stream name
366
+ storage: file
367
+ retention: workqueue
368
+ duplicate_window: 120 # seconds
369
+ discard: new
370
+ allow_direct: true # required by the web UI
371
+ subjects: ["jobs.%{name}.>"]
343
372
 
344
373
  consumer_config: &consumer_config
345
- ack_policy: explicit # ack policy (explicit, none, all), each individual message must be acknowledged
346
- max_deliver: 30 # maximum number of times a message will be delivered before it's considered failed. keep it above every job class's own retry; a job that still exceeds it is capped and dead-lettered early with a warning
347
- max_ack_pending: 20 # maximum number of messages with pending ack for this consumer
348
- ack_wait: 60 # time in seconds to wait for an ack before redelivering the message
349
- subject: jobs.%{name}.> # subject pattern for consumer, %{name} will be replaced with stream name
350
-
351
- consumers:
352
- jobs:
353
- critical:
354
- <<: *consumer_config
355
- priority: 50
356
- high:
357
- <<: *consumer_config
358
- priority: 30
359
- default:
360
- <<: *consumer_config
361
- priority: 15
362
- low:
363
- <<: *consumer_config
364
- priority: 5
365
- scheduled:
366
- <<: *consumer_config
367
- max_deliver: 5
368
- max_ack_pending: 100
369
- ack_wait: 10
374
+ ack_policy: explicit
375
+ max_deliver: 30 # keep it above every job class's retry
376
+ max_ack_pending: 20
377
+ ack_wait: 60 # seconds
378
+ subject: jobs.%{name}.>
370
379
 
371
380
  setup:
372
381
  jobs:
373
- critical:
374
- <<: *stream_config
375
- description: Very critical priority jobs
376
- high:
377
- <<: *stream_config
378
- description: Higher priority jobs
379
- default:
380
- <<: *stream_config
381
- description: Default priority jobs
382
- low:
383
- <<: *stream_config
384
- description: Lower priority jobs
385
- scheduled:
386
- <<: *stream_config
387
- description: Scheduled jobs
388
- dead:
389
- <<: *stream_config
382
+ critical: { <<: *stream_config }
383
+ default: { <<: *stream_config }
384
+ streams: # Cosmo::Stream processors are always yours to declare
385
+ events:
386
+ storage: file
390
387
  retention: limits
391
- max_msgs: 10000
392
- max_age: 604800 # 7d
393
- description: Broken jobs (DLQ)
394
-
395
- development:
396
- verbose: false
397
- concurrency: *concurrency
388
+ max_age: 86400
389
+ subjects: ["events.>"]
398
390
 
399
- staging:
400
- verbose: true
401
- concurrency: 3
391
+ consumers:
392
+ jobs:
393
+ critical: { <<: *consumer_config, priority: 50 }
394
+ default: { <<: *consumer_config, priority: 15 }
395
+ ```
396
+ `max_age`, `duplicate_window`, and `ack_wait` are in seconds. Without `setup.jobs`, `consumers.jobs.default` still
397
+ tunes the built-in consumer (e.g. just `ack_wait: 300`).
402
398
 
403
- production:
404
- concurrency: 3
399
+ **Service streams** — the `scheduled` stream (delayed jobs, crons), the `dead` stream (dead letters), and the stats
400
+ counters and KV buckets are Cosmo's own: they are not part of `cosmo.yml`, and `cosmo --setup` creates them next to
401
+ yours. Tune them in `Cosmo.configure`:
402
+ ```ruby
403
+ Cosmo.configure do |config|
404
+ config.replicas = 3 # every service stream and bucket, for a NATS cluster (default 1)
405
+ config.dead.max_age = "14d" # dead-letter retention: max_age, max_msgs (10_000), max_bytes (-1); default 7d
406
+ config.dead.enabled = false # drop jobs that give up instead of parking them
407
+ config.scheduled.enabled = false # no scheduled stream or scheduler: perform_in/perform_at and crons raise
408
+ config.batches.expiry = "1d" # how long batch tracking data lives (default 3d)
409
+ config.metrics.retention = "90d" # how long the Metrics tab's per-day numbers are kept (default 30d)
410
+ config.metrics.enabled = false # record nothing and hide the Metrics tab
411
+ end
405
412
  ```
413
+ Turning `scheduled` or `dead` off also hides their pages in the web UI. Stats can't be turned off — the web UI
414
+ depends on them.
406
415
 
407
- **Programmatic:**
416
+ **Programmatic:** `cosmo.yml` holds streams, consumers, and process settings; `Cosmo.configure` holds what YAML can't
417
+ express (loggers, middleware) and Cosmo's service settings. Command-line flags (`-c`, `-t`, `-p`) win over the file.
408
418
  ```ruby
409
- Cosmo::Config.set(:concurrency, 20)
410
- Cosmo::Config.set(:setup, :streams, :custom, { storage: "file", subjects: ["custom.>"] })
419
+ Cosmo.configure do |config|
420
+ config.logger = Rails.logger # any ::Logger; defaults to stdout
421
+ config.log_level = :debug # COSMO_LOG_LEVEL, when set, wins
422
+ end
411
423
  ```
424
+ Cosmo leaves a custom logger's formatter alone, so its lines lack Cosmo's `jid`/`elapsed` context unless you set
425
+ `logger.formatter = Cosmo::Logger::SimpleFormatter.new`. Trace output is dropped for loggers without a `trace` level.
412
426
 
413
427
  **Environment variables:**
414
- ```bash
415
- export NATS_URL=nats://localhost:4222
416
- export COSMO_JOBS_FETCH_TIMEOUT=0.1
417
- export COSMO_STREAMS_FETCH_TIMEOUT=0.1
418
- ```
428
+
429
+ | Variable | Default | Description |
430
+ |----------------------------|--------------------------------------|-------------------------------------------------------------------------------------|
431
+ | `NATS_URL` | `nats://localhost:4222` | NATS server(s) to connect to |
432
+ | `COSMO_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error` or `fatal` |
433
+ | `COSMO_CLIENT_NAME` | `cosmo-<program>-<host>-<pid>` | NATS connection name, shown in `nats server report connections` and `/connz` |
434
+ | `COSMO_JS_TIMEOUT` | `5` | JetStream API timeout in seconds; raise it for the web UI on a busy server |
435
+ | `COSMO_CONNECT_TIMEOUT` | `2` | NATS TCP connect timeout in seconds |
436
+ | `COSMO_JOBS_STREAMS` | all configured | Comma-separated job streams to subscribe to, when `--streams`/`--stream` isn't given |
437
+ | `COSMO_JOBS_FETCH_TIMEOUT` | `0.1` | Seconds a jobs worker waits on each fetch |
438
+ | `COSMO_WEB_POLL_INTERVAL` | `5` | Web UI auto-refresh interval in seconds |
419
439
 
420
440
 
421
441
  ## 🔧 Advanced Usage
@@ -423,10 +443,16 @@ export COSMO_STREAMS_FETCH_TIMEOUT=0.1
423
443
  ### Cron
424
444
 
425
445
  Recurring jobs, without a separate scheduler process. A schedule is just a message parked in the
426
- job's own NATS stream (requires NATS Server 2.14+) — NATS fires it on the cron expression, and it
427
- lands back in the stream as a regular job. Deploy it once; whatever's in NATS is exactly what runs
428
- and exactly what shows up in the web UI's **Crons** tab, where each entry can be inspected, run
429
- immediately, or deleted.
446
+ `scheduled` stream (requires NATS Server 2.14+) — NATS fires it on the cron expression, and the
447
+ worker's scheduler dispatches the job on to the stream that runs it, the same path a delayed job
448
+ takes. Deploy it once; whatever's in NATS is exactly what runs and exactly what shows up in the web
449
+ UI's **Crons** tab, where each entry can be inspected, run immediately, or deleted.
450
+
451
+ Every schedule lives in `scheduled` because NATS only lets a schedule fire at a subject its own
452
+ stream covers, and refuses `discard: new` on any stream with scheduling enabled. Keeping schedules
453
+ in one stream leaves every stream that actually runs jobs free to choose its own discard policy.
454
+ The trade is that cron firings are dispatched by the scheduler, so at least one worker has to be
455
+ running without `--no-scheduler`.
430
456
 
431
457
  Declare schedules right in `config/cosmo.yml`:
432
458
 
@@ -516,7 +542,7 @@ end
516
542
  end
517
543
  ```
518
544
  - Batch tracking data (pending counts, callbacks, results) expires automatically after
519
- `Config[:batch_expiry]` seconds (default: 3 days).
545
+ `config.batches.expiry` (default: 3 days), set in `Cosmo.configure`.
520
546
  - Open and finished batches — with pending/succeeded/failed counts — are listed live in the web UI's **Batches** tab.
521
547
  - Only `Cosmo::Job`-based jobs are tracked; the ActiveJob adapter doesn't currently participate in
522
548
  batches (see [`docs/active_job.md`](docs/active_job.md)).
@@ -592,6 +618,69 @@ If the proc returns something non-numeric/non-positive, or raises, the default b
592
618
  instead. Note: if this job class also sets `limit: { concurrency: ... }` (see above), `count`
593
619
  includes deliveries that were turned away for lack of a free slot, not just failed attempts.
594
620
 
621
+ **Error handlers** receive every error Cosmo rescues — each failed job attempt, a failing stream batch, fetch and
622
+ scheduler errors, and rejected messages — so one hook reports them all:
623
+ ```ruby
624
+ Cosmo.configure do |config|
625
+ config.error_handlers << ->(error, context) { Honeybadger.notify(error, context: context) }
626
+ end
627
+ ```
628
+ `context` is a Hash with a `:source` (`:job`, `:stream`, `:fetch`, `:scheduler`, `:reject`, `:retry_in`, `:limit`)
629
+ and what is known there: for a job its payload (`jid`, `class`, `args`, …), `stream`, `subject`, and `attempt`; for
630
+ a stream batch its `stream`, `processor`, and `size`. A handler that raises is logged and skipped.
631
+
632
+ ### Middleware
633
+
634
+ Every job execution on a worker runs through the server middleware chain. A middleware gets the
635
+ job instance, its parsed payload, and the NATS message, and yields to run the rest of the chain:
636
+ ```ruby
637
+ class TimingMiddleware
638
+ def initialize(threshold:)
639
+ @threshold = threshold
640
+ end
641
+
642
+ def call(job, data, message)
643
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
644
+ yield
645
+ ensure
646
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
647
+ job.logger.warn("#{data[:class]} took #{elapsed.round(2)}s on #{message.metadata.stream}") if elapsed > @threshold
648
+ end
649
+ end
650
+
651
+ Cosmo.configure do |config|
652
+ config.server_middleware do |chain|
653
+ chain.add TimingMiddleware, threshold: 5
654
+ end
655
+ end
656
+ ```
657
+ Register middleware at boot, before workers start (e.g. in a Rails initializer; loading `cosmo.yml` afterwards keeps
658
+ it). The chain starts with Cosmo's required middleware: `Cosmo::Middleware::Limit` (a job class's `limit:`
659
+ option), `Cosmo::Middleware::Busy` (the Web UI's Busy page), `Cosmo::Middleware::Totals` (processed/failed counts,
660
+ one per execution), and `Cosmo::Middleware::Metrics` (the Metrics tab). `add` appends innermost, after them; `prepend`, `insert_before`, `insert_after`, and `remove`
661
+ reorder the chain, so moving or removing the built-ins is at your own risk.
662
+ A middleware that raises sends the job through the usual retry/DLQ path, and one that doesn't yield skips the job
663
+ and acks it. To put a job back for later instead, raise `Cosmo::Job::Requeue.new(delay_in_seconds)`: the job is
664
+ redelivered without failing, though the redelivery still counts toward its `retry` and `max_deliver`.
665
+ A fresh instance is built for every job. `perform_sync` doesn't run the chain.
666
+
667
+ The **client** chain runs where a job is enqueued (`perform_async`, `perform_in`, `perform_at`, and the ActiveJob
668
+ adapter), around the publish. It starts empty, and a middleware gets the job class name, the payload Hash that will be
669
+ published, and the stream the job runs on:
670
+ ```ruby
671
+ class RequestIdMiddleware
672
+ def call(_job_class, payload, _stream)
673
+ payload[:request_id] = Current.request_id
674
+ yield
675
+ end
676
+ end
677
+
678
+ Cosmo.configure do |config|
679
+ config.client_middleware { |chain| chain.add RequestIdMiddleware }
680
+ end
681
+ ```
682
+ Not yielding stops the publish: `perform_async` returns `nil`, and a batch the job was joining doesn't wait for it.
683
+
595
684
  ### Testing
596
685
 
597
686
  ```ruby
@@ -631,29 +720,113 @@ ActiveJob::Base.queue_adapter = Cosmo::ActiveJobAdapter::Adapter.new
631
720
 
632
721
  **Sentry:**
633
722
  ```ruby
634
- require "cosmo/sentry/auto"
723
+ require "cosmo/middleware/sentry"
724
+
725
+ Cosmo.configure do |config|
726
+ config.server_middleware { |c| c.add Cosmo::Middleware::Sentry }
727
+ config.error_handlers << Cosmo::Middleware::Sentry::ERROR_HANDLER
728
+ end
635
729
  ```
636
- Wraps every job execution in a Sentry transaction (`queue.cosmonats`) and captures unhandled
637
- exceptions with the job's id, stream, subject, and retry count attached as context — no other
638
- setup beyond having `sentry-ruby` initialized.
730
+ The [middleware](#middleware) wraps every job execution in a Sentry transaction (`queue.cosmonats`), and the
731
+ [error handler](#error-handling) captures every error Cosmo rescues — failed jobs, stream batches, fetches, the
732
+ scheduler — with its context under `cosmonats`. No other setup beyond having `sentry-ruby` initialized.
639
733
 
640
734
 
641
735
  ## 🖥️ CLI Reference
642
736
 
643
737
  ```bash
644
- cosmo -C config/cosmo.yml --setup # Create streams in NATS (idempotent)
645
- cosmo -C config/cosmo.yml -c 20 -r ./app/jobs jobs # Jobs only
646
- cosmo -C config/cosmo.yml -c 20 streams # Streams only
647
- cosmo -C config/cosmo.yml -c 20 # Both
738
+ cosmo -C config/cosmo.yml --setup # Create streams in NATS (idempotent)
739
+ cosmo -C config/cosmo.yml -c 20 -r ./app/jobs jobs # Jobs only
740
+ cosmo -C config/cosmo.yml -c 20 streams # Streams only
741
+ cosmo -C config/cosmo.yml -c 20 # Both
742
+ cosmo -C config/cosmo.yml jobs --streams default,high # Jobs, limited to these streams
743
+ cosmo -C config/cosmo.yml jobs --stream critical # Jobs, limited to one stream
744
+ cosmo -C config/cosmo.yml jobs --no-scheduler # Jobs, without dispatching scheduled jobs
745
+ COSMO_JOBS_STREAMS=default,high cosmo jobs # Same filter, without touching the command line
746
+ ```
747
+
748
+ Each selected stream gets its own durable consumer, named `consumer-<stream>`. Every process that subscribes to a stream
749
+ pulls from that same consumer, so running a dedicated fleet per stream splits the work instead of duplicating it.
750
+ The `scheduled` stream is a service stream: it is always dispatched, and naming it in `--streams` selects nothing, use
751
+ `--no-scheduler` on workers that should not dispatch scheduled jobs. Unknown stream names abort the process.
752
+
753
+ **Global flags** (before the command):
754
+
755
+ | Flag | Description | Example |
756
+ |-------------------------|---------------------------------------|-----------------------|
757
+ | `-C, --config PATH` | Config file path | `-C config/cosmo.yml` |
758
+ | `-c, --concurrency INT` | Worker threads | `-c 20` |
759
+ | `-r, --require PATH` | Auto-require directory | `-r ./app/jobs` |
760
+ | `-t, --timeout NUM` | Shutdown timeout (sec) | `-t 60` |
761
+ | `-p, --http-port INT` | Serve `/health` and `/ping` on a port | `-p 9090` |
762
+ | `-S, --setup` | Setup streams & sync cron, then exit | `--setup` |
763
+ | `--init` | Write `config/cosmo.yml` with the defaults, then exit | `--init` |
764
+ | `-v, --version` | Print version and exit | `--version` |
765
+ | `-h, --help` | Show help and exit | `--help` |
766
+
767
+ **`jobs` command options:**
768
+
769
+ | Flag | Description | Example |
770
+ |-------------------------|--------------------------------------------------------------------|---------------------------------|
771
+ | `--streams NAMES` | Only subscribe to these job streams, instead of all configured ones | `--streams default,high` |
772
+ | `--stream NAME` | Same as `--streams`, for a single stream | `--stream default` |
773
+ | `--[no-]scheduler` | Dispatch scheduled jobs (default: yes) | `--no-scheduler` |
774
+ | `--subject NAME` | Job's subject | `--subject jobs.default.foo` |
775
+
776
+ `--streams`/`--stream` also read from `COSMO_JOBS_STREAMS` (comma-separated) when neither flag is given.
777
+
778
+ **`streams` command options** (`--stream`/`--subject`/`--consumer_name`/`--batch_size` apply only when running a single processor; ignored once `--processors` selects more than one):
779
+
780
+ | Flag | Description | Example |
781
+ |--------------------------|---------------------------------------|---------------------------------|
782
+ | `--processors NAMES` | Only run these stream processor classes | `--processors OrderProcessor` |
783
+ | `--stream NAME` | Stream name | `--stream orders` |
784
+ | `--subject NAME` | Subject name | `--subject orders.created` |
785
+ | `--consumer_name NAME` | Consumer name | `--consumer_name orders-consumer` |
786
+ | `--batch_size NUM` | Messages per fetch batch | `--batch_size 50` |
787
+
788
+
789
+ ### Signals
790
+
791
+ | Signal | Effect |
792
+ |------------------|--------------------------------------------------------------------------------------------|
793
+ | `INT`, `TERM` | Graceful shutdown: stop fetching, wait up to `--timeout` seconds for in-flight work, exit |
794
+ | `TSTP` | Quiet: stop fetching new work, keep running in-flight work |
795
+ | `CONT` | Resume fetching after `TSTP` |
796
+ | `USR1` | Quiet, then exit once in-flight work drains |
797
+ | `TTIN` | Log every thread's backtrace at `WARN`, tagged with the same `tid` as regular log lines |
798
+
799
+ ```bash
800
+ kill -TSTP <pid> # before a deploy: let the old worker finish what it has
801
+ kill -TTIN <pid> # a worker looks stuck: see what each thread is doing
648
802
  ```
649
803
 
650
- | Flag | Description | Example |
651
- |-------------------------|------------------------|-----------------------|
652
- | `-C, --config PATH` | Config file path | `-C config/cosmo.yml` |
653
- | `-c, --concurrency INT` | Worker threads | `-c 20` |
654
- | `-r, --require PATH` | Auto-require directory | `-r ./app/jobs` |
655
- | `-t, --timeout NUM` | Shutdown timeout (sec) | `-t 60` |
656
- | `-S, --setup` | Setup streams & exit | `--setup` |
804
+ The worker's state (`running`, `quiet`, `stopping`) is shown on the web UI's Processes list.
805
+
806
+ ### Health check
807
+
808
+ Pass `-p/--http-port` (or set `http.port` in the config) to start a small WEBrick HTTP server alongside the workers.
809
+ It needs the optional `rack` and `webrick` gems (see [Installation](#-installation)); without a port they're never loaded.
810
+
811
+ ```yaml
812
+ http:
813
+ port: 9090
814
+ host: 0.0.0.0 # default
815
+ ```
816
+
817
+ `GET /health` returns `200` when processors are running and NATS is connected, `503` otherwise:
818
+
819
+ ```bash
820
+ curl localhost:9090/health
821
+ # {"status":"ok","checks":{"engine":true,"nats":true}}
822
+ ```
823
+
824
+ `GET /ping` always returns `200 pong` without checking anything, handy as a liveness probe (use `/health` for readiness):
825
+
826
+ ```bash
827
+ curl localhost:9090/ping
828
+ # pong
829
+ ```
657
830
 
658
831
 
659
832
  ## 🚢 Deployment
@@ -725,9 +898,16 @@ sudo systemctl enable cosmo && sudo systemctl start cosmo
725
898
 
726
899
  **Web UI** — mount `Cosmo::Web` (see [Installation](#-installation)) for a live, htmx-powered dashboard:
727
900
  - **Jobs** — enqueued, scheduled, busy, and dead views, with per-job retry and delete
901
+ - **Processes** — the Jobs dashboard lists every live worker (idle ones included): host, pid, IP, NATS connection
902
+ name, command line, subscribed streams, busy/concurrency, running/quiet/stopping state, memory, NATS RTT and
903
+ reconnects. Each worker beats into the `_cosmoprocesses` KV bucket every 10s, and a crashed worker drops out within 60s
728
904
  - **Streams** — per-stream state (messages, bytes, consumers) with pause/resume
729
905
  - **Crons** — every schedule deployed in NATS, with run-now and delete
730
906
  - **Batches** — open and finished batches with pending/succeeded/failed counts
907
+ - **Metrics** — per job class over today, 7, or 30 days: runs, failures, average execution time (successful runs), and
908
+ average wait from enqueue to first delivery, plus a per-day chart of runs and average execution time. Recorded by
909
+ `Cosmo::Middleware::Metrics`, flushed by every worker's heartbeat into the internal `_cosmometrics` counters stream;
910
+ turn it off with `config.metrics.enabled = false`
731
911
  - Summary counters (processed / failed / busy / enqueued / retries / scheduled / dead) backed by a NATS KV counter, no separate metrics store needed
732
912
 
733
913
  **Structured logs:**
@@ -32,8 +32,7 @@ module Cosmo
32
32
  options = { stream: stream }.merge(cosmo_opts)
33
33
  options[:at] = timestamp if timestamp
34
34
 
35
- data = Job::Data.new(Executor.name, [job.serialize], options)
36
- Publisher.publish_job(data)
35
+ Job::Enqueuer.enqueue(Executor.name, [job.serialize], options)
37
36
  end
38
37
 
39
38
  # Returns Cosmo-specific options declared on the job class via
@@ -2,25 +2,28 @@
2
2
 
3
3
  module Cosmo
4
4
  module API
5
+ # Base for NATS counters (+Nats-Incr+) kept in a stream of their own. A subclass names it with +STREAM_NAME+ and
6
+ # +DESCRIPTION+ and may extend {.stream_config}; every instance counts under its namespace in that stream.
5
7
  class Counter
6
- STREAM_NAME = "_cosmostats"
8
+ # Creates the counters stream, or brings an existing one up to {.stream_config}.
9
+ #
10
+ # @return [void]
11
+ def self.setup!
12
+ Client.instance.setup_stream(self::STREAM_NAME, stream_config)
13
+ end
7
14
 
8
- def self.instance
9
- @instance ||= new("jobs")
15
+ # Every stored counter message carries the running total, so only the last one per subject is kept.
16
+ #
17
+ # @return [Hash]
18
+ def self.stream_config
19
+ { subjects: ["#{self::STREAM_NAME}.>"], allow_msg_counter: true, allow_direct: true, max_msgs_per_subject: 1,
20
+ num_replicas: Config.replicas, description: self::DESCRIPTION }
10
21
  end
11
22
 
12
23
  def initialize(namespace)
13
24
  @namespace = namespace
14
25
  end
15
26
 
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
27
  def increment(key, by: 1, msg_id: nil)
25
28
  publish(key, "+#{by}", msg_id: msg_id)
26
29
  end
@@ -32,12 +35,12 @@ module Cosmo
32
35
  alias decr decrement
33
36
 
34
37
  def reset(key)
35
- client.purge(STREAM_NAME, subject(key))
38
+ client.purge(self.class::STREAM_NAME, subject(key))
36
39
  end
37
40
  alias purge reset
38
41
 
39
42
  def get(key)
40
- raw = client.get_message(STREAM_NAME, direct: true, subject: subject(key))
43
+ raw = client.get_message(self.class::STREAM_NAME, direct: true, subject: subject(key))
41
44
  Utils::Json.parse(raw.data, default: { "val" => 0 })[:val].to_i
42
45
  rescue NATS::JetStream::Error::NotFound, NATS::JetStream::Error::ServiceUnavailable, NATS::IO::Timeout
43
46
  0
@@ -64,13 +67,13 @@ module Cosmo
64
67
  raise if rescued
65
68
 
66
69
  rescued = true
67
- client.create_stream(STREAM_NAME, subjects: ["#{STREAM_NAME}.>"], allow_msg_counter: true, allow_direct: true, description: "Cosmo statistics")
70
+ client.create_stream(self.class::STREAM_NAME, self.class.stream_config)
68
71
  retry
69
72
  end
70
73
  end
71
74
 
72
75
  def subject(key)
73
- "#{STREAM_NAME}.#{@namespace}.#{key}"
76
+ "#{self.class::STREAM_NAME}.#{@namespace}.#{key}"
74
77
  end
75
78
 
76
79
  def client
@@ -54,12 +54,31 @@ module Cosmo
54
54
  parts.join(".")
55
55
  end
56
56
 
57
- # Subject where NATS fires the generated job message.
58
- # Must be a subject covered by the same stream.
57
+ # Subject where NATS fires the generated job message. A schedule may only target a
58
+ # subject its own stream covers, so every schedule fires into the scheduled stream and
59
+ # the scheduler forwards the job on to {#dispatch_subject}.
59
60
  def target_subject
61
+ "jobs.#{::Cosmo::Job::StreamFilter::SCHEDULED}.#{job_name}"
62
+ end
63
+
64
+ # Subject the scheduler dispatches the fired job to, on the stream that runs it.
65
+ def dispatch_subject
60
66
  "jobs.#{@stream}.#{job_name}"
61
67
  end
62
68
 
69
+ # Headers stored on the schedule template. NATS copies the +X-+ ones onto every message
70
+ # it fires, which is how the scheduler learns where the job belongs.
71
+ def schedule_headers
72
+ headers = {
73
+ "Nats-Schedule" => @expression,
74
+ "Nats-Schedule-Target" => target_subject,
75
+ "X-Stream" => @stream,
76
+ "X-Subject" => dispatch_subject
77
+ }
78
+ headers["Nats-Schedule-Time-Zone"] = @timezone if @timezone
79
+ headers
80
+ end
81
+
63
82
  def job_name
64
83
  @job_name ||= Utils::String.underscore(@class_name)
65
84
  end
@@ -73,7 +92,8 @@ module Cosmo
73
92
  args: @args,
74
93
  name: @name,
75
94
  schedule_subject: schedule_subject,
76
- target_subject: target_subject
95
+ target_subject: target_subject,
96
+ dispatch_subject: dispatch_subject
77
97
  }.compact
78
98
  end
79
99