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.
- checksums.yaml +4 -4
- data/LICENSE.txt +21 -169
- data/README.md +296 -116
- data/lib/cosmo/active_job/adapter.rb +1 -2
- data/lib/cosmo/api/counter.rb +18 -15
- data/lib/cosmo/api/cron/entry.rb +23 -3
- data/lib/cosmo/api/cron.rb +25 -26
- data/lib/cosmo/api/job.rb +30 -0
- data/lib/cosmo/api/kv.rb +31 -11
- data/lib/cosmo/api/registry.rb +68 -0
- data/lib/cosmo/api/stats/busy.rb +63 -0
- data/lib/cosmo/api/stats/metrics.rb +109 -0
- data/lib/cosmo/api/stats/processes.rb +47 -0
- data/lib/cosmo/api/stats/totals.rb +27 -0
- data/lib/cosmo/api/stats.rb +8 -6
- data/lib/cosmo/api/stream.rb +1 -1
- data/lib/cosmo/api.rb +3 -2
- data/lib/cosmo/batch/counters.rb +19 -0
- data/lib/cosmo/batch.rb +15 -8
- data/lib/cosmo/cli.rb +46 -10
- data/lib/cosmo/client.rb +61 -7
- data/lib/cosmo/config/cosmo.yml +58 -0
- data/lib/cosmo/config/settings.rb +20 -0
- data/lib/cosmo/config.rb +144 -15
- data/lib/cosmo/engine.rb +103 -4
- data/lib/cosmo/heartbeat.rb +140 -0
- data/lib/cosmo/http_server/handler.rb +44 -0
- data/lib/cosmo/http_server/health.rb +39 -0
- data/lib/cosmo/http_server/ping.rb +23 -0
- data/lib/cosmo/http_server.rb +62 -0
- data/lib/cosmo/job/data.rb +17 -12
- data/lib/cosmo/job/enqueuer.rb +53 -0
- data/lib/cosmo/job/failure.rb +52 -0
- data/lib/cosmo/job/limit.rb +2 -2
- data/lib/cosmo/job/processor.rb +110 -73
- data/lib/cosmo/job/requeue.rb +20 -0
- data/lib/cosmo/job/stream_filter.rb +54 -0
- data/lib/cosmo/job.rb +10 -22
- data/lib/cosmo/logger.rb +15 -2
- data/lib/cosmo/middleware/busy.rb +15 -0
- data/lib/cosmo/middleware/chain.rb +137 -0
- data/lib/cosmo/middleware/limit.rb +50 -0
- data/lib/cosmo/middleware/metrics.rb +39 -0
- data/lib/cosmo/middleware/sentry.rb +61 -0
- data/lib/cosmo/middleware/totals.rb +29 -0
- data/lib/cosmo/middleware.rb +7 -0
- data/lib/cosmo/processor.rb +49 -8
- data/lib/cosmo/publisher.rb +1 -13
- data/lib/cosmo/railtie.rb +2 -2
- data/lib/cosmo/services.rb +69 -0
- data/lib/cosmo/stream/message.rb +2 -1
- data/lib/cosmo/stream/processor.rb +12 -1
- data/lib/cosmo/utils/hash.rb +7 -8
- data/lib/cosmo/utils/json.rb +1 -1
- data/lib/cosmo/utils/overrides.rb +16 -10
- data/lib/cosmo/utils/thread_pool.rb +5 -0
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +79 -0
- data/lib/cosmo/web/chart.rb +98 -0
- data/lib/cosmo/web/controllers/crons.rb +8 -0
- data/lib/cosmo/web/controllers/jobs.rb +43 -5
- data/lib/cosmo/web/controllers/metrics.rb +48 -0
- data/lib/cosmo/web/helpers/application.rb +20 -1
- data/lib/cosmo/web/views/batches/_table.erb +11 -0
- data/lib/cosmo/web/views/batches/index.erb +1 -1
- data/lib/cosmo/web/views/jobs/_busy.erb +70 -4
- data/lib/cosmo/web/views/jobs/_dead.erb +29 -16
- data/lib/cosmo/web/views/jobs/_enqueued.erb +10 -0
- data/lib/cosmo/web/views/jobs/_processes.erb +145 -0
- data/lib/cosmo/web/views/jobs/_scheduled.erb +1 -1
- data/lib/cosmo/web/views/jobs/_stats.erb +4 -0
- data/lib/cosmo/web/views/jobs/_tabs.erb +6 -0
- data/lib/cosmo/web/views/jobs/busy.erb +1 -1
- data/lib/cosmo/web/views/jobs/dead.erb +2 -2
- data/lib/cosmo/web/views/jobs/enqueued.erb +1 -1
- data/lib/cosmo/web/views/jobs/index.erb +7 -1
- data/lib/cosmo/web/views/jobs/scheduled.erb +1 -1
- data/lib/cosmo/web/views/layout.erb +1 -1
- data/lib/cosmo/web/views/metrics/_chart.erb +36 -0
- data/lib/cosmo/web/views/metrics/_panel.erb +13 -0
- data/lib/cosmo/web/views/metrics/_table.erb +41 -0
- data/lib/cosmo/web/views/metrics/index.erb +17 -0
- data/lib/cosmo/web/views/streams/index.erb +1 -1
- data/lib/cosmo/web.rb +5 -0
- data/lib/cosmo.rb +51 -0
- data/sig/cosmo/api/counter.rbs +3 -6
- data/sig/cosmo/api/cron/entry.rbs +2 -0
- data/sig/cosmo/api/cron.rbs +4 -2
- data/sig/cosmo/api/job.rbs +12 -0
- data/sig/cosmo/api/kv.rbs +7 -1
- data/sig/cosmo/api/registry.rbs +30 -0
- data/sig/cosmo/api/stats/busy.rbs +31 -0
- data/sig/cosmo/api/stats/metrics.rbs +42 -0
- data/sig/cosmo/api/stats/processes.rbs +19 -0
- data/sig/cosmo/api/stats/totals.rbs +18 -0
- data/sig/cosmo/batch/counters.rbs +10 -0
- data/sig/cosmo/batch.rbs +3 -4
- data/sig/cosmo/cli.rbs +6 -0
- data/sig/cosmo/client.rbs +15 -2
- data/sig/cosmo/config/settings.rbs +31 -0
- data/sig/cosmo/config.rbs +59 -5
- data/sig/cosmo/engine.rbs +29 -0
- data/sig/cosmo/heartbeat.rbs +38 -0
- data/sig/cosmo/http_server/handler.rbs +17 -0
- data/sig/cosmo/http_server/health.rbs +21 -0
- data/sig/cosmo/http_server/ping.rbs +14 -0
- data/sig/cosmo/http_server.rbs +22 -0
- data/sig/cosmo/job/data.rbs +5 -3
- data/sig/cosmo/job/enqueuer.rbs +11 -0
- data/sig/cosmo/job/failure.rbs +28 -0
- data/sig/cosmo/job/processor.rbs +18 -4
- data/sig/cosmo/job/requeue.rbs +9 -0
- data/sig/cosmo/job/stream_filter.rbs +25 -0
- data/sig/cosmo/job.rbs +0 -2
- data/sig/cosmo/logger.rbs +6 -0
- data/sig/cosmo/middleware/chain.rbs +51 -0
- data/sig/cosmo/middleware/sentry.rbs +14 -0
- data/sig/cosmo/middleware.rbs +35 -0
- data/sig/cosmo/processor.rbs +14 -1
- data/sig/cosmo/publisher.rbs +1 -9
- data/sig/cosmo/services.rbs +18 -0
- data/sig/cosmo/stream/message.rbs +3 -1
- data/sig/cosmo/stream/processor.rbs +4 -0
- data/sig/cosmo/utils/hash.rbs +2 -3
- data/sig/cosmo/utils/thread_pool.rbs +3 -0
- data/sig/cosmo.rbs +15 -0
- metadata +62 -22
- data/lib/cosmo/api/busy.rb +0 -66
- data/lib/cosmo/sentry/auto.rb +0 -6
- data/lib/cosmo/sentry/job_processor_middleware.rb +0 -66
- 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
|
[](https://rubygems.org/gems/cosmonats)
|
|
11
11
|
[](https://rubygems.org/gems/cosmonats)
|
|
12
12
|
[](https://www.ruby-lang.org)
|
|
13
|
-
[](LICENSE.txt)
|
|
14
14
|
[](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
|
-
|
|
216
|
+
```ruby
|
|
217
|
+
# Rails, with Devise
|
|
218
|
+
authenticate :user, ->(user) { user.admin? } do
|
|
219
|
+
mount Cosmo::Web => "/cosmo"
|
|
220
|
+
end
|
|
200
221
|
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
337
|
-
retention: workqueue
|
|
338
|
-
duplicate_window: 120 #
|
|
339
|
-
discard:
|
|
340
|
-
allow_direct: true #
|
|
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
|
|
346
|
-
max_deliver: 30
|
|
347
|
-
max_ack_pending: 20
|
|
348
|
-
ack_wait: 60
|
|
349
|
-
subject: jobs.%{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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
description: Broken jobs (DLQ)
|
|
394
|
-
|
|
395
|
-
development:
|
|
396
|
-
verbose: false
|
|
397
|
-
concurrency: *concurrency
|
|
388
|
+
max_age: 86400
|
|
389
|
+
subjects: ["events.>"]
|
|
398
390
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
404
|
-
|
|
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
|
|
410
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
`
|
|
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
|
|
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
|
-
|
|
637
|
-
|
|
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
|
|
645
|
-
cosmo -C config/cosmo.yml -c 20 -r ./app/jobs jobs
|
|
646
|
-
cosmo -C config/cosmo.yml -c 20 streams
|
|
647
|
-
cosmo -C config/cosmo.yml -c 20
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
|
|
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
|
data/lib/cosmo/api/counter.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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,
|
|
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
|
data/lib/cosmo/api/cron/entry.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|