cosmonats 0.7.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +199 -103
  3. data/lib/cosmo/active_job/adapter.rb +1 -2
  4. data/lib/cosmo/api/counter.rb +16 -16
  5. data/lib/cosmo/api/cron.rb +3 -0
  6. data/lib/cosmo/api/registry.rb +1 -1
  7. data/lib/cosmo/api/stats/busy.rb +1 -1
  8. data/lib/cosmo/api/stats/metrics.rb +109 -0
  9. data/lib/cosmo/api/stats/processes.rb +14 -4
  10. data/lib/cosmo/api/stats/totals.rb +4 -12
  11. data/lib/cosmo/api/stats.rb +3 -2
  12. data/lib/cosmo/batch/counters.rb +19 -0
  13. data/lib/cosmo/batch.rb +6 -6
  14. data/lib/cosmo/cli.rb +21 -5
  15. data/lib/cosmo/client.rb +28 -2
  16. data/lib/cosmo/config/cosmo.yml +58 -0
  17. data/lib/cosmo/config/settings.rb +20 -0
  18. data/lib/cosmo/config.rb +144 -19
  19. data/lib/cosmo/heartbeat.rb +11 -0
  20. data/lib/cosmo/job/data.rb +17 -12
  21. data/lib/cosmo/job/enqueuer.rb +53 -0
  22. data/lib/cosmo/job/limit.rb +2 -2
  23. data/lib/cosmo/job/processor.rb +27 -52
  24. data/lib/cosmo/job/requeue.rb +20 -0
  25. data/lib/cosmo/job.rb +8 -22
  26. data/lib/cosmo/logger.rb +15 -2
  27. data/lib/cosmo/middleware/busy.rb +15 -0
  28. data/lib/cosmo/middleware/chain.rb +137 -0
  29. data/lib/cosmo/middleware/limit.rb +50 -0
  30. data/lib/cosmo/middleware/metrics.rb +39 -0
  31. data/lib/cosmo/{sentry/job_processor_middleware.rb → middleware/sentry.rb} +26 -30
  32. data/lib/cosmo/middleware/totals.rb +29 -0
  33. data/lib/cosmo/middleware.rb +7 -0
  34. data/lib/cosmo/processor.rb +11 -5
  35. data/lib/cosmo/publisher.rb +1 -13
  36. data/lib/cosmo/railtie.rb +2 -2
  37. data/lib/cosmo/services.rb +69 -0
  38. data/lib/cosmo/stream/processor.rb +7 -1
  39. data/lib/cosmo/utils/hash.rb +7 -8
  40. data/lib/cosmo/version.rb +1 -1
  41. data/lib/cosmo/web/assets/app.css +58 -0
  42. data/lib/cosmo/web/chart.rb +98 -0
  43. data/lib/cosmo/web/controllers/crons.rb +8 -0
  44. data/lib/cosmo/web/controllers/jobs.rb +16 -2
  45. data/lib/cosmo/web/controllers/metrics.rb +48 -0
  46. data/lib/cosmo/web/helpers/application.rb +10 -0
  47. data/lib/cosmo/web/views/batches/_table.erb +11 -0
  48. data/lib/cosmo/web/views/jobs/_processes.erb +49 -35
  49. data/lib/cosmo/web/views/jobs/_stats.erb +4 -0
  50. data/lib/cosmo/web/views/jobs/_tabs.erb +6 -0
  51. data/lib/cosmo/web/views/jobs/index.erb +1 -1
  52. data/lib/cosmo/web/views/layout.erb +1 -1
  53. data/lib/cosmo/web/views/metrics/_chart.erb +36 -0
  54. data/lib/cosmo/web/views/metrics/_panel.erb +13 -0
  55. data/lib/cosmo/web/views/metrics/_table.erb +41 -0
  56. data/lib/cosmo/web/views/metrics/index.erb +17 -0
  57. data/lib/cosmo/web.rb +3 -0
  58. data/lib/cosmo.rb +38 -0
  59. data/sig/cosmo/api/counter.rbs +2 -3
  60. data/sig/cosmo/api/stats/busy.rbs +1 -1
  61. data/sig/cosmo/api/stats/metrics.rbs +42 -0
  62. data/sig/cosmo/api/stats/processes.rbs +3 -0
  63. data/sig/cosmo/api/stats/totals.rbs +3 -2
  64. data/sig/cosmo/batch/counters.rbs +10 -0
  65. data/sig/cosmo/batch.rbs +3 -4
  66. data/sig/cosmo/cli.rbs +2 -0
  67. data/sig/cosmo/client.rbs +3 -1
  68. data/sig/cosmo/config/settings.rbs +31 -0
  69. data/sig/cosmo/config.rbs +59 -5
  70. data/sig/cosmo/heartbeat.rbs +2 -0
  71. data/sig/cosmo/job/data.rbs +5 -3
  72. data/sig/cosmo/job/enqueuer.rbs +11 -0
  73. data/sig/cosmo/job/processor.rbs +3 -5
  74. data/sig/cosmo/job/requeue.rbs +9 -0
  75. data/sig/cosmo/job.rbs +0 -2
  76. data/sig/cosmo/logger.rbs +6 -0
  77. data/sig/cosmo/middleware/chain.rbs +51 -0
  78. data/sig/cosmo/middleware/sentry.rbs +14 -0
  79. data/sig/cosmo/middleware.rbs +35 -0
  80. data/sig/cosmo/processor.rbs +2 -0
  81. data/sig/cosmo/publisher.rbs +1 -9
  82. data/sig/cosmo/services.rbs +18 -0
  83. data/sig/cosmo/stream/processor.rbs +2 -0
  84. data/sig/cosmo/utils/hash.rbs +2 -3
  85. data/sig/cosmo.rbs +11 -0
  86. metadata +30 -3
  87. data/lib/cosmo/sentry/auto.rb +0 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 96e901f7365784bf2fb7087311112efb263f295eddbe25a96ff32de4ebfe0652
4
- data.tar.gz: b1f7327b126f85e50222af5adf2172b8fab84aa051209e9c0e9b478254bcb8a8
3
+ metadata.gz: 685ae4ff6735c88c3605b58f86168ce4acc2509d7dd66c7335cdeab50fc9ffa4
4
+ data.tar.gz: b508d08c685c6d726c1e3c56c79432d4afc952384d04ae210d0d6d673caf6c01
5
5
  SHA512:
6
- metadata.gz: 1ab5c6cc101edf8dbf8909f544ed9a9fd8603c76f073dad60c5251e086195518e07ec5a6815bcf382b05c7b80f67fb6994a4615be933b33c29103ea3082990af
7
- data.tar.gz: 85b15998b6286a331558e847835f6a94cf0936adae88e70a18035335b2414096d583e4619b7b2e5f5c7e579117c0d3626dbb99e189084fc3cf7aedc25dfc4fba
6
+ metadata.gz: 074bed30d8889561ecf77fd90d5d599e3f3cc9f487df781e08d88135b71b8ab31826b77dad8420b830e1a00632f047c46fee139d6a4e9817c0ca414795e46575
7
+ data.tar.gz: 99d1169938570c778981447df1385d177ad5a432e355690498c8ddac8a1e4eef6f9eedeb593b1541dc2dd51472906827e80032dcd2b0e4484435d7dce1e67771
data/README.md CHANGED
@@ -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,7 @@ 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))
170
171
 
171
172
  **Optional gems** — not installed with `cosmonats`, add them only for the features you use:
172
173
 
@@ -208,33 +209,39 @@ mount Cosmo::Web => "/cosmo"
208
209
  map "/cosmo" { run Cosmo::Web }
209
210
  ```
210
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.
211
215
 
212
- ## 🚀 Quick Start
216
+ ```ruby
217
+ # Rails, with Devise
218
+ authenticate :user, ->(user) { user.admin? } do
219
+ mount Cosmo::Web => "/cosmo"
220
+ end
213
221
 
214
- ### 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
+ ```
215
231
 
216
- ```yaml
217
- concurrency: 5 # Number of worker threads
218
232
 
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.>`
233
+ ## 🚀 Quick Start
234
+
235
+ ### 1. Configure (optional)
227
236
 
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)
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
235
242
  ```
236
243
 
237
- ### 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)
238
245
 
239
246
  ```bash
240
247
  bundle exec cosmo -S
@@ -293,6 +300,16 @@ ReportJob.perform_at(Time.parse("2026-01-25 10:00"), 42) # Scheduled
293
300
  ReportJob.perform_sync(42) # Inline, no NATS (great for tests)
294
301
  ```
295
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
+
296
313
  ### Streams
297
314
 
298
315
  ```ruby
@@ -338,98 +355,87 @@ message.term # Permanent failure, no retry
338
355
  **NATS subjects** follow a dot-separated hierarchy (`events.clicks.homepage`).
339
356
  The `>` wildcard matches everything after that prefix. Think of subjects as topic names — flexible routing with no extra configuration.
340
357
 
341
- **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:
342
362
  ```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)
363
+ concurrency: 10
347
364
 
348
365
  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
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}.>"]
356
372
 
357
373
  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
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}.>
383
379
 
384
380
  setup:
385
381
  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
382
+ critical: { <<: *stream_config }
383
+ default: { <<: *stream_config }
384
+ streams: # Cosmo::Stream processors are always yours to declare
385
+ events:
386
+ storage: file
404
387
  retention: limits
405
- max_msgs: 10000
406
- max_age: 604800 # 7d
407
- description: Broken jobs (DLQ)
388
+ max_age: 86400
389
+ subjects: ["events.>"]
408
390
 
409
- development:
410
- verbose: false
411
- concurrency: *concurrency
412
-
413
- staging:
414
- verbose: true
415
- 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`).
416
398
 
417
- production:
418
- 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
419
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.
420
415
 
421
- **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.
422
418
  ```ruby
423
- Cosmo::Config.set(:concurrency, 20)
424
- 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
425
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.
426
426
 
427
427
  **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
- ```
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 |
433
439
 
434
440
 
435
441
  ## 🔧 Advanced Usage
@@ -536,7 +542,7 @@ 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).
545
+ `config.batches.expiry` (default: 3 days), set in `Cosmo.configure`.
540
546
  - Open and finished batches — with pending/succeeded/failed counts — are listed live in the web UI's **Batches** tab.
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)).
@@ -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.
@@ -807,11 +900,14 @@ sudo systemctl enable cosmo && sudo systemctl start cosmo
807
900
  - **Jobs** — enqueued, scheduled, busy, and dead views, with per-job retry and delete
808
901
  - **Processes** — the Jobs dashboard lists every live worker (idle ones included): host, pid, IP, NATS connection
809
902
  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
903
+ reconnects. Each worker beats into the `_cosmoprocesses` KV bucket every 10s, and a crashed worker drops out within 60s
812
904
  - **Streams** — per-stream state (messages, bytes, consumers) with pause/resume
813
905
  - **Crons** — every schedule deployed in NATS, with run-now and delete
814
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`
815
911
  - Summary counters (processed / failed / busy / enqueued / retries / scheduled / dead) backed by a NATS KV counter, no separate metrics store needed
816
912
 
817
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,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
@@ -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
@@ -32,7 +32,10 @@ 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
41
  client.publish(e.schedule_subject, e.job_payload, stream: STREAM, header: e.schedule_headers)
@@ -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)
@@ -9,7 +9,7 @@ module Cosmo
9
9
  class Busy < Registry
10
10
  TTL = 70
11
11
  HEARTBEAT = 30
12
- BUCKET = "cosmo_jobs_busy"
12
+ BUCKET = "_cosmobusy"
13
13
 
14
14
  def initialize
15
15
  super
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module API
5
+ module Stats
6
+ # Per-day metrics for every job class: runs, failures, and execution and wait time, kept in their own counters
7
+ # stream for +config.metrics.retention+. Workers {#record} executions in memory and the heartbeat {#flush}es them,
8
+ # so a job costs no NATS round trip; a crashed worker loses at most one heartbeat's worth.
9
+ class Metrics < Counter
10
+ STREAM_NAME = "_cosmometrics"
11
+ DESCRIPTION = "Cosmo metrics"
12
+ DAY = 86_400
13
+
14
+ def self.instance
15
+ @instance ||= new
16
+ end
17
+
18
+ # Each day's counters expire +config.metrics.retention+ after their last update.
19
+ #
20
+ # @return [Hash]
21
+ def self.stream_config
22
+ super.merge(max_age: Config.to_ns(Utils::Duration.parse(Config.metrics.retention)))
23
+ end
24
+
25
+ def initialize
26
+ super("jobs")
27
+ @lock = Mutex.new
28
+ @buffer = buffer
29
+ end
30
+
31
+ # Adds one execution to the buffer {#flush} writes.
32
+ #
33
+ # @param job_class [String]
34
+ # @param exec_ms [Numeric, nil] how long a successful execution took
35
+ # @param wait_ms [Numeric, nil] time from enqueue to the job's first delivery
36
+ # @param failed [Boolean]
37
+ # @return [void]
38
+ def record(job_class, exec_ms: nil, wait_ms: nil, failed: false)
39
+ key = [Time.now.utc.strftime("%Y%m%d"), job_class.gsub("::", "-")]
40
+ @lock.synchronize do
41
+ totals = @buffer[key]
42
+ failed ? totals[:failed] += 1 : totals[:count] += 1
43
+ totals[:exec_ms] += exec_ms if exec_ms
44
+ next unless wait_ms
45
+
46
+ totals[:waited] += 1
47
+ totals[:wait_ms] += wait_ms
48
+ end
49
+ end
50
+
51
+ # Writes the buffered executions to the counters and empties the buffer.
52
+ #
53
+ # @return [void]
54
+ def flush
55
+ pending = @lock.synchronize { @buffer.tap { @buffer = buffer } }
56
+ pending.each do |(day, job), totals|
57
+ totals.each { |field, value| increment("#{day}.#{job}.#{field}", by: value.round) if value.positive? }
58
+ end
59
+ end
60
+
61
+ # @param days [Integer] today and the days before it
62
+ # @return [Array<Hash>] per job class +:job+, +:count+, +:failed+, +:exec_ms+ and +:wait_ms+ (averages in
63
+ # milliseconds, nil without data), busiest first
64
+ def summary(days: 1)
65
+ totals = Hash.new { |hash, job| hash[job] = Hash.new(0) }
66
+ read(days).each { |(_, job, field), value| totals[job][field] += value }
67
+ totals.map { |job, sums| row(sums).merge(job: job) }.sort_by { -_1[:count] }
68
+ end
69
+
70
+ # @param job_class [String]
71
+ # @param days [Integer]
72
+ # @return [Array<Hash>] one +{ date:, count:, failed:, exec_ms:, wait_ms: }+ per day, oldest first
73
+ def daily(job_class, days: 30)
74
+ sums = Hash.new { |hash, day| hash[day] = Hash.new(0) }
75
+ read(days).each { |(day, job, field), value| sums[day][field] += value if job == job_class }
76
+ dates(days).map { |day| row(sums[day]).merge(date: day) }
77
+ end
78
+
79
+ private
80
+
81
+ def buffer
82
+ Hash.new { |hash, key| hash[key] = Hash.new(0) }
83
+ end
84
+
85
+ def read(days)
86
+ filters = dates(days).map { "#{STREAM_NAME}.#{@namespace}.#{_1}.>" }
87
+ client.last_messages(STREAM_NAME, filters).map do |subject, data|
88
+ day, job, field = subject.split(".").last(3)
89
+ [[day, job.gsub("-", "::"), field.to_sym], Utils::Json.parse(data, default: {})[:val].to_i]
90
+ end
91
+ end
92
+
93
+ def dates(days)
94
+ now = Time.now.utc
95
+ (days - 1).downto(0).map { (now - (_1 * DAY)).strftime("%Y%m%d") }
96
+ end
97
+
98
+ def row(sums)
99
+ { count: sums[:count], failed: sums[:failed], exec_ms: average(sums[:exec_ms], sums[:count]),
100
+ wait_ms: average(sums[:wait_ms], sums[:waited]) }
101
+ end
102
+
103
+ def average(total, count)
104
+ total.fdiv(count).round(1) if count.positive?
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end