cosmonats 0.7.0 → 0.9.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 (147) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +219 -113
  3. data/lib/cosmo/active_job/adapter.rb +1 -2
  4. data/lib/cosmo/api/counter.rb +17 -17
  5. data/lib/cosmo/api/cron/entry.rb +10 -11
  6. data/lib/cosmo/api/cron.rb +6 -3
  7. data/lib/cosmo/api/job.rb +1 -1
  8. data/lib/cosmo/api/kv.rb +3 -3
  9. data/lib/cosmo/api/registry.rb +1 -1
  10. data/lib/cosmo/api/stats/busy.rb +6 -1
  11. data/lib/cosmo/api/stats/metrics.rb +109 -0
  12. data/lib/cosmo/api/stats/processes.rb +14 -4
  13. data/lib/cosmo/api/stats/totals.rb +4 -12
  14. data/lib/cosmo/api/stats.rb +18 -2
  15. data/lib/cosmo/api/stream.rb +7 -2
  16. data/lib/cosmo/batch/counters.rb +19 -0
  17. data/lib/cosmo/batch.rb +6 -6
  18. data/lib/cosmo/cli.rb +21 -5
  19. data/lib/cosmo/client.rb +37 -14
  20. data/lib/cosmo/config/cosmo.yml +58 -0
  21. data/lib/cosmo/config/settings.rb +20 -0
  22. data/lib/cosmo/config.rb +144 -19
  23. data/lib/cosmo/heartbeat.rb +11 -0
  24. data/lib/cosmo/job/data.rb +17 -12
  25. data/lib/cosmo/job/enqueuer.rb +53 -0
  26. data/lib/cosmo/job/limit.rb +2 -2
  27. data/lib/cosmo/job/processor.rb +32 -58
  28. data/lib/cosmo/job/requeue.rb +20 -0
  29. data/lib/cosmo/job.rb +8 -22
  30. data/lib/cosmo/logger.rb +15 -2
  31. data/lib/cosmo/middleware/busy.rb +15 -0
  32. data/lib/cosmo/middleware/chain.rb +137 -0
  33. data/lib/cosmo/middleware/limit.rb +50 -0
  34. data/lib/cosmo/middleware/metrics.rb +39 -0
  35. data/lib/cosmo/{sentry/job_processor_middleware.rb → middleware/sentry.rb} +26 -30
  36. data/lib/cosmo/middleware/totals.rb +29 -0
  37. data/lib/cosmo/middleware.rb +7 -0
  38. data/lib/cosmo/processor.rb +11 -5
  39. data/lib/cosmo/publisher.rb +1 -13
  40. data/lib/cosmo/railtie.rb +2 -2
  41. data/lib/cosmo/services.rb +69 -0
  42. data/lib/cosmo/stream/processor.rb +7 -1
  43. data/lib/cosmo/utils/hash.rb +7 -8
  44. data/lib/cosmo/utils.rb +0 -1
  45. data/lib/cosmo/version.rb +1 -1
  46. data/lib/cosmo/web/assets/app.css +271 -571
  47. data/lib/cosmo/web/assets/app.js +158 -0
  48. data/lib/cosmo/web/assets/fonts/OFL.txt +92 -0
  49. data/lib/cosmo/web/assets/fonts/geist-mono.woff2 +0 -0
  50. data/lib/cosmo/web/assets/fonts/geist.woff2 +0 -0
  51. data/lib/cosmo/web/assets/logo.svg +10 -0
  52. data/lib/cosmo/web/cache.rb +75 -0
  53. data/lib/cosmo/web/chart.rb +122 -0
  54. data/lib/cosmo/web/circuit.rb +55 -0
  55. data/lib/cosmo/web/context.rb +0 -4
  56. data/lib/cosmo/web/controllers/application.rb +16 -0
  57. data/lib/cosmo/web/controllers/batches.rb +1 -0
  58. data/lib/cosmo/web/controllers/crons.rb +17 -2
  59. data/lib/cosmo/web/controllers/jobs.rb +59 -54
  60. data/lib/cosmo/web/controllers/metrics.rb +49 -0
  61. data/lib/cosmo/web/controllers/overview.rb +70 -0
  62. data/lib/cosmo/web/controllers/processes.rb +33 -0
  63. data/lib/cosmo/web/controllers/streams.rb +26 -28
  64. data/lib/cosmo/web/helpers/application.rb +67 -0
  65. data/lib/cosmo/web/renderer.rb +19 -6
  66. data/lib/cosmo/web/throughput.rb +104 -0
  67. data/lib/cosmo/web/views/batches/_table.erb +42 -43
  68. data/lib/cosmo/web/views/batches/index.erb +3 -10
  69. data/lib/cosmo/web/views/crons/_table.erb +32 -53
  70. data/lib/cosmo/web/views/crons/index.erb +2 -9
  71. data/lib/cosmo/web/views/jobs/_args.erb +5 -0
  72. data/lib/cosmo/web/views/jobs/_busy.erb +25 -121
  73. data/lib/cosmo/web/views/jobs/_dead.erb +42 -82
  74. data/lib/cosmo/web/views/jobs/_enqueued.erb +33 -113
  75. data/lib/cosmo/web/views/jobs/_panel.erb +29 -0
  76. data/lib/cosmo/web/views/jobs/_queue_row.erb +18 -0
  77. data/lib/cosmo/web/views/jobs/_queues.erb +20 -0
  78. data/lib/cosmo/web/views/jobs/_scheduled.erb +20 -54
  79. data/lib/cosmo/web/views/jobs/index.erb +4 -17
  80. data/lib/cosmo/web/views/layout.erb +73 -22
  81. data/lib/cosmo/web/views/metrics/_chart.erb +41 -0
  82. data/lib/cosmo/web/views/metrics/_panel.erb +13 -0
  83. data/lib/cosmo/web/views/metrics/_table.erb +25 -0
  84. data/lib/cosmo/web/views/metrics/index.erb +9 -0
  85. data/lib/cosmo/web/views/overview/_badges.erb +3 -0
  86. data/lib/cosmo/web/views/overview/_kpis.erb +19 -0
  87. data/lib/cosmo/web/views/overview/_running.erb +18 -0
  88. data/lib/cosmo/web/views/overview/_throughput.erb +50 -0
  89. data/lib/cosmo/web/views/overview/_workers.erb +25 -0
  90. data/lib/cosmo/web/views/overview/index.erb +28 -0
  91. data/lib/cosmo/web/views/processes/_table.erb +67 -0
  92. data/lib/cosmo/web/views/processes/index.erb +1 -0
  93. data/lib/cosmo/web/views/shared/_pager.erb +22 -0
  94. data/lib/cosmo/web/views/shared/unavailable.erb +7 -0
  95. data/lib/cosmo/web/views/streams/_info.erb +28 -86
  96. data/lib/cosmo/web/views/streams/_list.erb +7 -0
  97. data/lib/cosmo/web/views/streams/_pause_banner.erb +8 -16
  98. data/lib/cosmo/web/views/streams/_stream.erb +52 -0
  99. data/lib/cosmo/web/views/streams/index.erb +3 -11
  100. data/lib/cosmo/web/views/streams/info.erb +3 -11
  101. data/lib/cosmo/web.rb +67 -33
  102. data/lib/cosmo.rb +38 -0
  103. data/sig/cosmo/api/counter.rbs +2 -3
  104. data/sig/cosmo/api/cron/entry.rbs +2 -1
  105. data/sig/cosmo/api/stats/busy.rbs +3 -1
  106. data/sig/cosmo/api/stats/metrics.rbs +42 -0
  107. data/sig/cosmo/api/stats/processes.rbs +3 -0
  108. data/sig/cosmo/api/stats/totals.rbs +3 -2
  109. data/sig/cosmo/api/stats.rbs +4 -0
  110. data/sig/cosmo/api/stream.rbs +2 -0
  111. data/sig/cosmo/batch/counters.rbs +10 -0
  112. data/sig/cosmo/batch.rbs +3 -4
  113. data/sig/cosmo/cli.rbs +2 -0
  114. data/sig/cosmo/client.rbs +4 -1
  115. data/sig/cosmo/config/settings.rbs +31 -0
  116. data/sig/cosmo/config.rbs +59 -5
  117. data/sig/cosmo/heartbeat.rbs +2 -0
  118. data/sig/cosmo/job/data.rbs +5 -3
  119. data/sig/cosmo/job/enqueuer.rbs +11 -0
  120. data/sig/cosmo/job/processor.rbs +3 -5
  121. data/sig/cosmo/job/requeue.rbs +9 -0
  122. data/sig/cosmo/job.rbs +0 -2
  123. data/sig/cosmo/logger.rbs +6 -0
  124. data/sig/cosmo/middleware/chain.rbs +51 -0
  125. data/sig/cosmo/middleware/sentry.rbs +14 -0
  126. data/sig/cosmo/middleware.rbs +35 -0
  127. data/sig/cosmo/processor.rbs +2 -0
  128. data/sig/cosmo/publisher.rbs +1 -9
  129. data/sig/cosmo/services.rbs +18 -0
  130. data/sig/cosmo/stream/processor.rbs +2 -0
  131. data/sig/cosmo/utils/hash.rbs +2 -3
  132. data/sig/cosmo.rbs +11 -0
  133. metadata +60 -20
  134. data/lib/cosmo/sentry/auto.rb +0 -6
  135. data/lib/cosmo/utils/overrides.rb +0 -206
  136. data/lib/cosmo/utils/warnings.rb +0 -17
  137. data/lib/cosmo/web/controllers/actions.rb +0 -16
  138. data/lib/cosmo/web/views/actions/index.erb +0 -7
  139. data/lib/cosmo/web/views/jobs/_processes.erb +0 -131
  140. data/lib/cosmo/web/views/jobs/_stats.erb +0 -73
  141. data/lib/cosmo/web/views/jobs/_tabs.erb +0 -8
  142. data/lib/cosmo/web/views/jobs/busy.erb +0 -18
  143. data/lib/cosmo/web/views/jobs/dead.erb +0 -18
  144. data/lib/cosmo/web/views/jobs/enqueued.erb +0 -18
  145. data/lib/cosmo/web/views/jobs/scheduled.erb +0 -18
  146. data/lib/cosmo/web/views/streams/_stream_row.erb +0 -42
  147. data/lib/cosmo/web/views/streams/_table.erb +0 -25
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 96e901f7365784bf2fb7087311112efb263f295eddbe25a96ff32de4ebfe0652
4
- data.tar.gz: b1f7327b126f85e50222af5adf2172b8fab84aa051209e9c0e9b478254bcb8a8
3
+ metadata.gz: 40e30ee2fff77d85db1a3deedf05e040c72dca39d64da01220517d3d5ae8048b
4
+ data.tar.gz: 6f0e5a50448674483f09f54938336940899ea81fe46ce66da23478eeee871bac
5
5
  SHA512:
6
- metadata.gz: 1ab5c6cc101edf8dbf8909f544ed9a9fd8603c76f073dad60c5251e086195518e07ec5a6815bcf382b05c7b80f67fb6994a4615be933b33c29103ea3082990af
7
- data.tar.gz: 85b15998b6286a331558e847835f6a94cf0936adae88e70a18035335b2414096d583e4619b7b2e5f5c7e579117c0d3626dbb99e189084fc3cf7aedc25dfc4fba
6
+ metadata.gz: 6d57b79ee594fd7a7ace94e1e3f5d70de87b9a413b85389cddb9db51f39ed09542bd0f0a7d3c4308e7009b7a2d9997b9be0c2965efa61e259bd07baf9bfa4734
7
+ data.tar.gz: 8122f92e84e4176a403678225ab66b753270ab5e49334568bc3e88cfa292451bc63ea6f5383bdb458ab89f8b319a6ed33b7af471f636ffe7314978c956b6acb1
data/README.md CHANGED
@@ -58,8 +58,6 @@ bundle exec cosmo -C config/cosmo.yml -c 20 jobs # Jobs only
58
58
  bundle exec cosmo -C config/cosmo.yml -c 20 streams # Streams only
59
59
  ```
60
60
 
61
- ![webui.gif](webui.gif)
62
-
63
61
  ## 📖 Index
64
62
 
65
63
  - [Why?](#-why)
@@ -77,6 +75,7 @@ bundle exec cosmo -C config/cosmo.yml -c 20 streams # Streams only
77
75
  - [Concurrency Limiting](#concurrency-limiting)
78
76
  - [Custom Serializers](#custom-serializers)
79
77
  - [Error Handling](#error-handling)
78
+ - [Middleware](#middleware)
80
79
  - [Testing](#testing)
81
80
  - [Integrations](#integrations)
82
81
  - [CLI Reference](#-cli-reference)
@@ -166,7 +165,7 @@ nothing else to run.
166
165
  gem "cosmonats"
167
166
  ```
168
167
 
169
- **Requirements:** Ruby ≥ 3.1, NATS Server ([install guide](https://docs.nats.io/running-a-nats-service/introduction/installation))
168
+ **Requirements:** Ruby ≥ 3.1, NATS Server ≥ 2.14 with JetStream enabled ([install guide](https://docs.nats.io/running-a-nats-service/introduction/installation))
170
169
 
171
170
  **Optional gems** — not installed with `cosmonats`, add them only for the features you use:
172
171
 
@@ -208,33 +207,39 @@ mount Cosmo::Web => "/cosmo"
208
207
  map "/cosmo" { run Cosmo::Web }
209
208
  ```
210
209
 
210
+ > [!WARNING]
211
+ > `Cosmo::Web` ships with **no authentication and no CSRF protection**, yet it exposes destructive actions
212
+ > (retrying and deleting jobs, pausing streams, running and deleting crons). Never expose it unprotected.
211
213
 
212
- ## 🚀 Quick Start
214
+ ```ruby
215
+ # Rails, with Devise
216
+ authenticate :user, ->(user) { user.admin? } do
217
+ mount Cosmo::Web => "/cosmo"
218
+ end
219
+
220
+ # Any Rack app (config.ru), with HTTP Basic auth
221
+ map "/cosmo" do
222
+ use Rack::Auth::Basic, "Cosmo" do |user, password|
223
+ Rack::Utils.secure_compare(user, ENV.fetch("COSMO_WEB_USER")) &
224
+ Rack::Utils.secure_compare(password, ENV.fetch("COSMO_WEB_PASSWORD"))
225
+ end
226
+ run Cosmo::Web
227
+ end
228
+ ```
213
229
 
214
- ### 1. Create `config/cosmo.yml`
215
230
 
216
- ```yaml
217
- concurrency: 5 # Number of worker threads
231
+ ## 🚀 Quick Start
232
+
233
+ ### 1. Configure (optional)
218
234
 
219
- consumers: # Declare consumer groups for streams, things that pull messages and process them
220
- jobs: # Consumer configs for jobs (or streams)
221
- default: # Stream name
222
- ack_policy: explicit # Acknowledgment required for each message can be explicit, none, or all
223
- max_deliver: 30 # Max retry attempts before sending to a dead stream. Safety ceiling only, keep it above every job class's own retry
224
- max_ack_pending: 10 # Max messages waiting for ack, if exceeded, the server will stop delivering new messages until some are acked
225
- ack_wait: 15 # Seconds to wait for ack before redelivering
226
- subject: jobs.%{name}.> # Subject pattern for this consumer, %{name} replaced with stream name, becomes `jobs.default.>`
235
+ Nothing is required: without `config/cosmo.yml`, Cosmo runs one `default` job stream plus its own service streams.
236
+ To change anything, write Cosmo's defaults into your project and edit them:
227
237
 
228
- setup: # Initial stream creation only `cosmo -S`
229
- jobs: # Stream configs for jobs (or streams)
230
- default: # Stream name
231
- storage: file # Storage type (file or memory)
232
- retention: workqueue # Retention policy (limits, interest, workqueue). workqueue - deletes acked/nacked, limits - append only
233
- subjects: ["jobs.%{name}.>"] # Subject pattern for this stream, %{name} replaced with stream name
234
- allow_direct: true # Allow direct messages to stream (required for web UI)
238
+ ```bash
239
+ bundle exec cosmo --init # writes config/cosmo.yml
235
240
  ```
236
241
 
237
- ### 2. Create streams in NATS (one-time), grabs config from setup section of `config/cosmo.yml`
242
+ ### 2. Create streams in NATS (one-time, idempotent)
238
243
 
239
244
  ```bash
240
245
  bundle exec cosmo -S
@@ -293,6 +298,16 @@ ReportJob.perform_at(Time.parse("2026-01-25 10:00"), 42) # Scheduled
293
298
  ReportJob.perform_sync(42) # Inline, no NATS (great for tests)
294
299
  ```
295
300
 
301
+ Inside `perform`, the job instance knows about the message it came from:
302
+
303
+ | Accessor | Value |
304
+ |----------------|-----------------------------------------------------------------------------------------|
305
+ | `jid` | Job id, returned by `perform_async` |
306
+ | `attempt` | 1-based delivery attempt |
307
+ | `enqueued_at` | `Time` the job was enqueued; for a cron job, when NATS fired the schedule |
308
+ | `scheduled_by` | Subject of the cron schedule that fired the job, `nil` for jobs enqueued any other way |
309
+ | `batch_id` | Id of the [batch](#batches) the job belongs to, `nil` outside a batch |
310
+
296
311
  ### Streams
297
312
 
298
313
  ```ruby
@@ -338,98 +353,89 @@ message.term # Permanent failure, no retry
338
353
  **NATS subjects** follow a dot-separated hierarchy (`events.clicks.homepage`).
339
354
  The `>` wildcard matches everything after that prefix. Think of subjects as topic names — flexible routing with no extra configuration.
340
355
 
341
- **Full `config/cosmo.yml` example:**
356
+ **`config/cosmo.yml` is optional.** Cosmo ships its defaults in
357
+ [`lib/cosmo/config/cosmo.yml`](lib/cosmo/config/cosmo.yml) (`cosmo --init` copies it into your project), and your
358
+ file is deep-merged over them: keep only what you change. The one list that is not merged is your job streams —
359
+ listing any under `setup.jobs` replaces the built-in `default` stream and its consumer:
342
360
  ```yaml
343
- timeout: 25 # Shutdown timeout in seconds
344
- concurrency: &concurrency 1 # Number of worker threads
345
- max_retries: &max_retries 3 # Default max retries
346
- batch_expiry: 259200 # Seconds before a Batch's tracking data expires (default: 3 days)
361
+ concurrency: 10
347
362
 
348
363
  stream_config: &stream_config
349
- storage: file # storage type (file or memory)
350
- retention: workqueue # retention policy (limits, interest, workqueue)
351
- duplicate_window: 120 # time window for duplicate message detection in seconds
352
- discard: old # what to drop when the stream is full (new rejects publishes, old evicts the oldest)
353
- allow_direct: true # allow direct messages to stream, required for web UI
354
- subjects:
355
- - jobs.%{name}.> # subject pattern for stream, %{name} will be replaced with stream name
364
+ storage: file
365
+ retention: workqueue
366
+ duplicate_window: 120 # seconds
367
+ discard: new
368
+ allow_direct: true # required by the web UI
369
+ subjects: ["jobs.%{name}.>"]
356
370
 
357
371
  consumer_config: &consumer_config
358
- ack_policy: explicit # ack policy (explicit, none, all), each individual message must be acknowledged
359
- 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
360
- max_ack_pending: 20 # maximum number of messages with pending ack for this consumer
361
- ack_wait: 60 # time in seconds to wait for an ack before redelivering the message
362
- subject: jobs.%{name}.> # subject pattern for consumer, %{name} will be replaced with stream name
363
-
364
- consumers:
365
- jobs:
366
- critical:
367
- <<: *consumer_config
368
- priority: 50
369
- high:
370
- <<: *consumer_config
371
- priority: 30
372
- default:
373
- <<: *consumer_config
374
- priority: 15
375
- low:
376
- <<: *consumer_config
377
- priority: 5
378
- scheduled:
379
- <<: *consumer_config
380
- max_deliver: 5
381
- max_ack_pending: 100
382
- ack_wait: 10
372
+ ack_policy: explicit
373
+ max_deliver: 30 # keep it above every job class's retry
374
+ max_ack_pending: 20
375
+ ack_wait: 60 # seconds
376
+ subject: jobs.%{name}.>
383
377
 
384
378
  setup:
385
379
  jobs:
386
- critical:
387
- <<: *stream_config
388
- description: Very critical priority jobs
389
- high:
390
- <<: *stream_config
391
- description: Higher priority jobs
392
- default:
393
- <<: *stream_config
394
- description: Default priority jobs
395
- low:
396
- <<: *stream_config
397
- description: Lower priority jobs
398
- scheduled:
399
- <<: *stream_config
400
- discard: old # required here: NATS refuses `discard: new` on the stream holding cron schedules
401
- description: Scheduled jobs
402
- dead:
403
- <<: *stream_config
380
+ critical: { <<: *stream_config }
381
+ default: { <<: *stream_config }
382
+ streams: # Cosmo::Stream processors are always yours to declare
383
+ events:
384
+ storage: file
404
385
  retention: limits
405
- max_msgs: 10000
406
- max_age: 604800 # 7d
407
- description: Broken jobs (DLQ)
408
-
409
- development:
410
- verbose: false
411
- concurrency: *concurrency
386
+ max_age: 86400
387
+ subjects: ["events.>"]
412
388
 
413
- staging:
414
- verbose: true
415
- concurrency: 3
389
+ consumers:
390
+ jobs:
391
+ critical: { <<: *consumer_config, priority: 50 }
392
+ default: { <<: *consumer_config, priority: 15 }
393
+ ```
394
+ `max_age`, `duplicate_window`, and `ack_wait` are in seconds. Without `setup.jobs`, `consumers.jobs.default` still
395
+ tunes the built-in consumer (e.g. just `ack_wait: 300`).
416
396
 
417
- production:
418
- concurrency: 3
397
+ **Service streams** — the `scheduled` stream (delayed jobs, crons), the `dead` stream (dead letters), and the stats
398
+ counters and KV buckets are Cosmo's own: they are not part of `cosmo.yml`, and `cosmo --setup` creates them next to
399
+ yours. Tune them in `Cosmo.configure`:
400
+ ```ruby
401
+ Cosmo.configure do |config|
402
+ config.replicas = 3 # every service stream and bucket, for a NATS cluster (default 1)
403
+ config.dead.max_age = "14d" # dead-letter retention: max_age, max_msgs (10_000), max_bytes (-1); default 7d
404
+ config.dead.enabled = false # drop jobs that give up instead of parking them
405
+ config.scheduled.enabled = false # no scheduled stream or scheduler: perform_in/perform_at and crons raise
406
+ config.batches.expiry = "1d" # how long batch tracking data lives (default 3d)
407
+ config.metrics.retention = "90d" # how long the Metrics tab's per-day numbers are kept (default 30d)
408
+ config.metrics.enabled = false # record nothing and hide the Metrics tab
409
+ end
419
410
  ```
411
+ Turning `scheduled` or `dead` off also hides their pages in the web UI. Stats can't be turned off — the web UI
412
+ depends on them.
420
413
 
421
- **Programmatic:**
414
+ **Programmatic:** `cosmo.yml` holds streams, consumers, and process settings; `Cosmo.configure` holds what YAML can't
415
+ express (loggers, middleware) and Cosmo's service settings. Command-line flags (`-c`, `-t`, `-p`) win over the file.
422
416
  ```ruby
423
- Cosmo::Config.set(:concurrency, 20)
424
- Cosmo::Config.set(:setup, :streams, :custom, { storage: "file", subjects: ["custom.>"] })
417
+ Cosmo.configure do |config|
418
+ config.logger = Rails.logger # any ::Logger; defaults to stdout
419
+ config.log_level = :debug # COSMO_LOG_LEVEL, when set, wins
420
+ end
425
421
  ```
422
+ Cosmo leaves a custom logger's formatter alone, so its lines lack Cosmo's `jid`/`elapsed` context unless you set
423
+ `logger.formatter = Cosmo::Logger::SimpleFormatter.new`. Trace output is dropped for loggers without a `trace` level.
426
424
 
427
425
  **Environment variables:**
428
- ```bash
429
- export NATS_URL=nats://localhost:4222
430
- export COSMO_JOBS_FETCH_TIMEOUT=0.1
431
- export COSMO_STREAMS_FETCH_TIMEOUT=0.1
432
- ```
426
+
427
+ | Variable | Default | Description |
428
+ |----------------------------|--------------------------------------|-------------------------------------------------------------------------------------|
429
+ | `NATS_URL` | `nats://localhost:4222` | NATS server(s) to connect to |
430
+ | `COSMO_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error` or `fatal` |
431
+ | `COSMO_CLIENT_NAME` | `cosmo-<program>-<host>-<pid>` | NATS connection name, shown in `nats server report connections` and `/connz` |
432
+ | `COSMO_JS_TIMEOUT` | `5` | JetStream API timeout in seconds; raise it for the web UI on a busy server |
433
+ | `COSMO_CONNECT_TIMEOUT` | `2` | NATS TCP connect timeout in seconds |
434
+ | `NATS_MAX_RECONNECT_ATTEMPTS` | `-1` | Reconnect attempts before giving up on NATS; `-1` keeps trying through any outage |
435
+ | `COSMO_JOBS_STREAMS` | all configured | Comma-separated job streams to subscribe to, when `--streams`/`--stream` isn't given |
436
+ | `COSMO_JOBS_FETCH_TIMEOUT` | `0.1` | Seconds a jobs worker waits on each fetch |
437
+ | `COSMO_WEB_POLL_INTERVAL` | `5` | Web UI auto-refresh interval in seconds |
438
+ | `COSMO_WEB_CACHE_TTL` | `2` | Seconds the web UI shares a refresh between viewers; `0` turns sharing off |
433
439
 
434
440
 
435
441
  ## 🔧 Advanced Usage
@@ -536,8 +542,8 @@ end
536
542
  end
537
543
  ```
538
544
  - Batch tracking data (pending counts, callbacks, results) expires automatically after
539
- `Config[:batch_expiry]` seconds (default: 3 days).
540
- - Open and finished batches — with pending/succeeded/failed counts — are listed live in the web UI's **Batches** tab.
545
+ `config.batches.expiry` (default: 3 days), set in `Cosmo.configure`.
546
+ - Open and finished batches — with pending/succeeded/failed counts — are listed live on the web UI's **Batches** page.
541
547
  - Only `Cosmo::Job`-based jobs are tracked; the ActiveJob adapter doesn't currently participate in
542
548
  batches (see [`docs/active_job.md`](docs/active_job.md)).
543
549
 
@@ -612,6 +618,69 @@ If the proc returns something non-numeric/non-positive, or raises, the default b
612
618
  instead. Note: if this job class also sets `limit: { concurrency: ... }` (see above), `count`
613
619
  includes deliveries that were turned away for lack of a free slot, not just failed attempts.
614
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
+
615
684
  ### Testing
616
685
 
617
686
  ```ruby
@@ -651,11 +720,16 @@ ActiveJob::Base.queue_adapter = Cosmo::ActiveJobAdapter::Adapter.new
651
720
 
652
721
  **Sentry:**
653
722
  ```ruby
654
- 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
655
729
  ```
656
- Wraps every job execution in a Sentry transaction (`queue.cosmonats`) and captures unhandled
657
- exceptions with the job's id, stream, subject, and retry count attached as context — no other
658
- 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.
659
733
 
660
734
 
661
735
  ## 🖥️ CLI Reference
@@ -684,7 +758,9 @@ The `scheduled` stream is a service stream: it is always dispatched, and naming
684
758
  | `-c, --concurrency INT` | Worker threads | `-c 20` |
685
759
  | `-r, --require PATH` | Auto-require directory | `-r ./app/jobs` |
686
760
  | `-t, --timeout NUM` | Shutdown timeout (sec) | `-t 60` |
761
+ | `-p, --http-port INT` | Serve `/health` and `/ping` on a port | `-p 9090` |
687
762
  | `-S, --setup` | Setup streams & sync cron, then exit | `--setup` |
763
+ | `--init` | Write `config/cosmo.yml` with the defaults, then exit | `--init` |
688
764
  | `-v, --version` | Print version and exit | `--version` |
689
765
  | `-h, --help` | Show help and exit | `--help` |
690
766
 
@@ -710,6 +786,23 @@ The `scheduled` stream is a service stream: it is always dispatched, and naming
710
786
  | `--batch_size NUM` | Messages per fetch batch | `--batch_size 50` |
711
787
 
712
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
802
+ ```
803
+
804
+ The worker's state (`running`, `quiet`, `stopping`) is shown on the web UI's Processes list.
805
+
713
806
  ### Health check
714
807
 
715
808
  Pass `-p/--http-port` (or set `http.port` in the config) to start a small WEBrick HTTP server alongside the workers.
@@ -803,16 +896,29 @@ sudo systemctl enable cosmo && sudo systemctl start cosmo
803
896
 
804
897
  ## 📊 Monitoring
805
898
 
806
- **Web UI** — mount `Cosmo::Web` (see [Installation](#-installation)) for a live, htmx-powered dashboard:
807
- - **Jobs** — enqueued, scheduled, busy, and dead views, with per-job retry and delete
808
- - **Processes** — the Jobs dashboard lists every live worker (idle ones included): host, pid, IP, NATS connection
809
- name, command line, subscribed streams, busy/concurrency, running/quiet/stopping state, memory, NATS RTT and
810
- reconnects. Each worker
811
- beats into the `cosmo_processes` KV bucket every 10s, and a crashed worker drops out within 60s
812
- - **Streams** — per-stream state (messages, bytes, consumers) with pause/resume
899
+ **Web UI** — mount `Cosmo::Web` (see [Installation](#-installation)) for a live, htmx-powered dashboard with a
900
+ light, dark, or automatic (system) theme. Fonts and assets are bundled, so it loads nothing from a CDN:
901
+ - **Overview** — processed, failed, busy, enqueued, retries, scheduled and dead totals; a 7- or 30-day throughput
902
+ chart of successful and failed runs; a grid of every worker thread, busy ones colored by stream; the queues; and the
903
+ jobs running right now, longest first
904
+ - **Jobs** — each job queue with its size, busy jobs and redeliveries, paused and resumed in place; then busy,
905
+ enqueued (per queue), scheduled and dead jobs, with retry and delete for dead ones and their error and backtrace
906
+ - **Processes** — every live worker (idle ones included): host, pid, IP, NATS connection name, command line,
907
+ subscribed streams, busy/concurrency, running/quiet/stopping state, memory, NATS RTT and reconnects. Each worker
908
+ beats into the `_cosmoprocesses` KV bucket every 10s, and a crashed worker drops out within 60s
909
+ - **Streams** — every stream with its subjects, messages, size, retention and replicas, its consumers' pending,
910
+ ack-pending and redelivered counts, and pause/resume
813
911
  - **Crons** — every schedule deployed in NATS, with run-now and delete
814
- - **Batches** — open and finished batches with pending/succeeded/failed counts
815
- - Summary counters (processed / failed / busy / enqueued / retries / scheduled / dead) backed by a NATS KV counter, no separate metrics store needed
912
+ - **Batches** — open and finished batches with their progress, succeeded/failed/pending counts and callbacks
913
+ - **Metrics** — per job class over today, 7, or 30 days: runs, failures, average execution time (successful runs), and
914
+ average wait from enqueue to first delivery, plus a per-day chart of runs and average execution time. Recorded by
915
+ `Cosmo::Middleware::Metrics`, flushed by every worker's heartbeat into the internal `_cosmometrics` counters stream;
916
+ turn it off with `config.metrics.enabled = false`
917
+
918
+ Each panel refreshes `COSMO_WEB_POLL_INTERVAL` seconds after its previous response, never on top of one still
919
+ running, backs off while refreshes fail, and pauses in a hidden tab. Identical refreshes from several viewers share a
920
+ single NATS read for `COSMO_WEB_CACHE_TTL` seconds, and while NATS is unreachable the UI answers at once instead of
921
+ holding your app server's threads.
816
922
 
817
923
  **Structured logs:**
818
924
  ```
@@ -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,22 +2,22 @@
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"
7
- STREAM_CONFIG = {
8
- subjects: ["#{STREAM_NAME}.>"],
9
- allow_msg_counter: true,
10
- allow_direct: true,
11
- max_msgs_per_subject: 1,
12
- description: "Cosmo statistics"
13
- }.freeze
14
-
15
- # Creates the counters stream, or brings an existing one up to STREAM_CONFIG. Every stored
16
- # counter message carries the running total, so only the last one per subject is kept.
8
+ # Creates the counters stream, or brings an existing one up to {.stream_config}.
17
9
  #
18
10
  # @return [void]
19
11
  def self.setup!
20
- Client.instance.setup_stream(STREAM_NAME, STREAM_CONFIG)
12
+ Client.instance.setup_stream(self::STREAM_NAME, stream_config)
13
+ end
14
+
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 }
21
21
  end
22
22
 
23
23
  def initialize(namespace)
@@ -35,12 +35,12 @@ module Cosmo
35
35
  alias decr decrement
36
36
 
37
37
  def reset(key)
38
- client.purge(STREAM_NAME, subject(key))
38
+ client.purge(self.class::STREAM_NAME, subject(key))
39
39
  end
40
40
  alias purge reset
41
41
 
42
42
  def get(key)
43
- 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))
44
44
  Utils::Json.parse(raw.data, default: { "val" => 0 })[:val].to_i
45
45
  rescue NATS::JetStream::Error::NotFound, NATS::JetStream::Error::ServiceUnavailable, NATS::IO::Timeout
46
46
  0
@@ -58,7 +58,7 @@ module Cosmo
58
58
  def publish(key, value, msg_id: nil)
59
59
  rescued = nil
60
60
  headers = { "Nats-Incr" => value }
61
- headers["Nats-Msg-Id"] = msg_id if msg_id
61
+ headers[NATS::JetStream::Header::MSG_ID] = msg_id if msg_id
62
62
 
63
63
  begin
64
64
  ack = client.publish(subject(key), "", header: headers)
@@ -67,13 +67,13 @@ module Cosmo
67
67
  raise if rescued
68
68
 
69
69
  rescued = true
70
- client.create_stream(STREAM_NAME, STREAM_CONFIG)
70
+ client.create_stream(self.class::STREAM_NAME, self.class.stream_config)
71
71
  retry
72
72
  end
73
73
  end
74
74
 
75
75
  def subject(key)
76
- "#{STREAM_NAME}.#{@namespace}.#{key}"
76
+ "#{self.class::STREAM_NAME}.#{@namespace}.#{key}"
77
77
  end
78
78
 
79
79
  def client
@@ -66,17 +66,16 @@ module Cosmo
66
66
  "jobs.#{@stream}.#{job_name}"
67
67
  end
68
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
69
+ # The +schedule:+ option of nats-pure's JetStream#publish that makes the template a schedule.
70
+ # @-shortcuts (+@daily+, +@every 5m+) travel as the cron expression, which the server understands.
71
+ def schedule
72
+ { cron: @expression, target: target_subject, time_zone: @timezone }.compact
73
+ end
74
+
75
+ # Headers stored on the schedule template. NATS copies them onto every message it fires,
76
+ # which is how the scheduler learns where the job belongs.
77
+ def headers
78
+ { "X-Stream" => @stream, "X-Subject" => dispatch_subject }
80
79
  end
81
80
 
82
81
  def job_name
@@ -32,10 +32,13 @@ module Cosmo
32
32
 
33
33
  # Publish (or replace) a schedule message in NATS.
34
34
  # @return [Hash, nil] the persisted schedule as a hash, or nil on failure
35
+ # @raise [SchedulingDisabledError] when scheduling is turned off
35
36
  def upsert!(class_name: nil, stream: nil, schedule: nil, args: [], timezone: nil, name: nil)
37
+ raise SchedulingDisabledError unless Config.scheduled.enabled
38
+
36
39
  e = Entry.new(class_name: class_name, stream: stream, expression: schedule,
37
40
  args: args, timezone: timezone, name: name)
38
- client.publish(e.schedule_subject, e.job_payload, stream: STREAM, header: e.schedule_headers)
41
+ client.publish(e.schedule_subject, e.job_payload, stream: STREAM, header: e.headers, schedule: e.schedule)
39
42
  build_from_nats(e.schedule_subject)
40
43
  end
41
44
 
@@ -91,8 +94,8 @@ module Cosmo
91
94
  {
92
95
  class: body[:class],
93
96
  stream: headers["X-Stream"],
94
- schedule: headers["Nats-Schedule"],
95
- timezone: headers["Nats-Schedule-Time-Zone"],
97
+ schedule: headers[NATS::JetStream::Header::SCHEDULE],
98
+ timezone: headers[NATS::JetStream::Header::SCHEDULE_TIME_ZONE],
96
99
  args: body[:args] || [],
97
100
  name: name_from_subject(subject),
98
101
  schedule_subject: subject,
data/lib/cosmo/api/job.rb CHANGED
@@ -62,7 +62,7 @@ module Cosmo
62
62
  end
63
63
 
64
64
  def timestamp
65
- headers&.dig("Nats-Time-Stamp")
65
+ headers&.dig(NATS::JetStream::Header::TIME_STAMP)
66
66
  end
67
67
 
68
68
  # @return [Time, nil] when the message was stored in its stream, parsed from +Nats-Time-Stamp+
data/lib/cosmo/api/kv.rb CHANGED
@@ -98,14 +98,14 @@ module Cosmo
98
98
  results
99
99
  end
100
100
 
101
- # CAS = Compare-And-Swap: publish +value+ with a per-message Nats-TTL,
101
+ # CAS = Compare-And-Swap: publish +value+ with a per-message TTL of +ttl+ whole seconds (at least 1),
102
102
  # but only if the subject's current last sequence matches +last_seq+
103
103
  # (sent as the Nats-Expected-Last-Subject-Sequence header). Raises
104
104
  # NATS::KeyValue::KeyWrongLastSequenceError if it doesn't match --
105
105
  # e.g. last_seq: 0 means "only publish if nothing exists here yet".
106
106
  def publish_cas(key, value, ttl, last_seq:)
107
- headers = { "Nats-Expected-Last-Subject-Sequence" => last_seq.to_s, "Nats-TTL" => "#{ttl.to_i}s" }
108
- client.js.publish("$KV.#{@name}.#{key}", value, header: headers)
107
+ headers = { NATS::JetStream::Header::EXPECTED_LAST_SUBJECT_SEQUENCE => last_seq.to_s }
108
+ client.js.publish("$KV.#{@name}.#{key}", value, header: headers, ttl: [ttl.to_i, 1].max)
109
109
  rescue NATS::JetStream::Error::APIError => e
110
110
  raise NATS::KeyValue::KeyWrongLastSequenceError, e.description if e.err_code == 10_071
111
111
 
@@ -57,7 +57,7 @@ module Cosmo
57
57
  private
58
58
 
59
59
  def bucket
60
- KV.new(self.class::BUCKET, { ttl: self.class::TTL })
60
+ KV.new(self.class::BUCKET, { ttl: self.class::TTL, replicas: Config.replicas })
61
61
  end
62
62
 
63
63
  def sanitize(key)