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.
- checksums.yaml +4 -4
- data/README.md +199 -103
- data/lib/cosmo/active_job/adapter.rb +1 -2
- data/lib/cosmo/api/counter.rb +16 -16
- data/lib/cosmo/api/cron.rb +3 -0
- data/lib/cosmo/api/registry.rb +1 -1
- data/lib/cosmo/api/stats/busy.rb +1 -1
- data/lib/cosmo/api/stats/metrics.rb +109 -0
- data/lib/cosmo/api/stats/processes.rb +14 -4
- data/lib/cosmo/api/stats/totals.rb +4 -12
- data/lib/cosmo/api/stats.rb +3 -2
- data/lib/cosmo/batch/counters.rb +19 -0
- data/lib/cosmo/batch.rb +6 -6
- data/lib/cosmo/cli.rb +21 -5
- data/lib/cosmo/client.rb +28 -2
- data/lib/cosmo/config/cosmo.yml +58 -0
- data/lib/cosmo/config/settings.rb +20 -0
- data/lib/cosmo/config.rb +144 -19
- data/lib/cosmo/heartbeat.rb +11 -0
- data/lib/cosmo/job/data.rb +17 -12
- data/lib/cosmo/job/enqueuer.rb +53 -0
- data/lib/cosmo/job/limit.rb +2 -2
- data/lib/cosmo/job/processor.rb +27 -52
- data/lib/cosmo/job/requeue.rb +20 -0
- data/lib/cosmo/job.rb +8 -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/{sentry/job_processor_middleware.rb → middleware/sentry.rb} +26 -30
- data/lib/cosmo/middleware/totals.rb +29 -0
- data/lib/cosmo/middleware.rb +7 -0
- data/lib/cosmo/processor.rb +11 -5
- 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/processor.rb +7 -1
- data/lib/cosmo/utils/hash.rb +7 -8
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +58 -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 +16 -2
- data/lib/cosmo/web/controllers/metrics.rb +48 -0
- data/lib/cosmo/web/helpers/application.rb +10 -0
- data/lib/cosmo/web/views/batches/_table.erb +11 -0
- data/lib/cosmo/web/views/jobs/_processes.erb +49 -35
- 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/index.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.rb +3 -0
- data/lib/cosmo.rb +38 -0
- data/sig/cosmo/api/counter.rbs +2 -3
- data/sig/cosmo/api/stats/busy.rbs +1 -1
- data/sig/cosmo/api/stats/metrics.rbs +42 -0
- data/sig/cosmo/api/stats/processes.rbs +3 -0
- data/sig/cosmo/api/stats/totals.rbs +3 -2
- data/sig/cosmo/batch/counters.rbs +10 -0
- data/sig/cosmo/batch.rbs +3 -4
- data/sig/cosmo/cli.rbs +2 -0
- data/sig/cosmo/client.rbs +3 -1
- data/sig/cosmo/config/settings.rbs +31 -0
- data/sig/cosmo/config.rbs +59 -5
- data/sig/cosmo/heartbeat.rbs +2 -0
- data/sig/cosmo/job/data.rbs +5 -3
- data/sig/cosmo/job/enqueuer.rbs +11 -0
- data/sig/cosmo/job/processor.rbs +3 -5
- data/sig/cosmo/job/requeue.rbs +9 -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 +2 -0
- data/sig/cosmo/publisher.rbs +1 -9
- data/sig/cosmo/services.rbs +18 -0
- data/sig/cosmo/stream/processor.rbs +2 -0
- data/sig/cosmo/utils/hash.rbs +2 -3
- data/sig/cosmo.rbs +11 -0
- metadata +30 -3
- data/lib/cosmo/sentry/auto.rb +0 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 685ae4ff6735c88c3605b58f86168ce4acc2509d7dd66c7335cdeab50fc9ffa4
|
|
4
|
+
data.tar.gz: b508d08c685c6d726c1e3c56c79432d4afc952384d04ae210d0d6d673caf6c01
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
216
|
+
```ruby
|
|
217
|
+
# Rails, with Devise
|
|
218
|
+
authenticate :user, ->(user) { user.admin? } do
|
|
219
|
+
mount Cosmo::Web => "/cosmo"
|
|
220
|
+
end
|
|
213
221
|
|
|
214
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
350
|
-
retention: workqueue
|
|
351
|
-
duplicate_window: 120 #
|
|
352
|
-
discard:
|
|
353
|
-
allow_direct: true #
|
|
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
|
|
359
|
-
max_deliver: 30
|
|
360
|
-
max_ack_pending: 20
|
|
361
|
-
ack_wait: 60
|
|
362
|
-
subject: jobs.%{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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
description: Broken jobs (DLQ)
|
|
388
|
+
max_age: 86400
|
|
389
|
+
subjects: ["events.>"]
|
|
408
390
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
418
|
-
|
|
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
|
|
424
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
`
|
|
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
|
|
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
|
-
|
|
657
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
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,
|
|
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,
|
|
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
|
data/lib/cosmo/api/cron.rb
CHANGED
|
@@ -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)
|
data/lib/cosmo/api/registry.rb
CHANGED
data/lib/cosmo/api/stats/busy.rb
CHANGED
|
@@ -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
|