cosmonats 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +219 -113
- data/lib/cosmo/active_job/adapter.rb +1 -2
- data/lib/cosmo/api/counter.rb +17 -17
- data/lib/cosmo/api/cron/entry.rb +10 -11
- data/lib/cosmo/api/cron.rb +6 -3
- data/lib/cosmo/api/job.rb +1 -1
- data/lib/cosmo/api/kv.rb +3 -3
- data/lib/cosmo/api/registry.rb +1 -1
- data/lib/cosmo/api/stats/busy.rb +6 -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 +18 -2
- data/lib/cosmo/api/stream.rb +7 -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 +37 -14
- 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 +32 -58
- 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/utils.rb +0 -1
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +271 -571
- data/lib/cosmo/web/assets/app.js +158 -0
- data/lib/cosmo/web/assets/fonts/OFL.txt +92 -0
- data/lib/cosmo/web/assets/fonts/geist-mono.woff2 +0 -0
- data/lib/cosmo/web/assets/fonts/geist.woff2 +0 -0
- data/lib/cosmo/web/assets/logo.svg +10 -0
- data/lib/cosmo/web/cache.rb +75 -0
- data/lib/cosmo/web/chart.rb +122 -0
- data/lib/cosmo/web/circuit.rb +55 -0
- data/lib/cosmo/web/context.rb +0 -4
- data/lib/cosmo/web/controllers/application.rb +16 -0
- data/lib/cosmo/web/controllers/batches.rb +1 -0
- data/lib/cosmo/web/controllers/crons.rb +17 -2
- data/lib/cosmo/web/controllers/jobs.rb +59 -54
- data/lib/cosmo/web/controllers/metrics.rb +49 -0
- data/lib/cosmo/web/controllers/overview.rb +70 -0
- data/lib/cosmo/web/controllers/processes.rb +33 -0
- data/lib/cosmo/web/controllers/streams.rb +26 -28
- data/lib/cosmo/web/helpers/application.rb +67 -0
- data/lib/cosmo/web/renderer.rb +19 -6
- data/lib/cosmo/web/throughput.rb +104 -0
- data/lib/cosmo/web/views/batches/_table.erb +42 -43
- data/lib/cosmo/web/views/batches/index.erb +3 -10
- data/lib/cosmo/web/views/crons/_table.erb +32 -53
- data/lib/cosmo/web/views/crons/index.erb +2 -9
- data/lib/cosmo/web/views/jobs/_args.erb +5 -0
- data/lib/cosmo/web/views/jobs/_busy.erb +25 -121
- data/lib/cosmo/web/views/jobs/_dead.erb +42 -82
- data/lib/cosmo/web/views/jobs/_enqueued.erb +33 -113
- data/lib/cosmo/web/views/jobs/_panel.erb +29 -0
- data/lib/cosmo/web/views/jobs/_queue_row.erb +18 -0
- data/lib/cosmo/web/views/jobs/_queues.erb +20 -0
- data/lib/cosmo/web/views/jobs/_scheduled.erb +20 -54
- data/lib/cosmo/web/views/jobs/index.erb +4 -17
- data/lib/cosmo/web/views/layout.erb +73 -22
- data/lib/cosmo/web/views/metrics/_chart.erb +41 -0
- data/lib/cosmo/web/views/metrics/_panel.erb +13 -0
- data/lib/cosmo/web/views/metrics/_table.erb +25 -0
- data/lib/cosmo/web/views/metrics/index.erb +9 -0
- data/lib/cosmo/web/views/overview/_badges.erb +3 -0
- data/lib/cosmo/web/views/overview/_kpis.erb +19 -0
- data/lib/cosmo/web/views/overview/_running.erb +18 -0
- data/lib/cosmo/web/views/overview/_throughput.erb +50 -0
- data/lib/cosmo/web/views/overview/_workers.erb +25 -0
- data/lib/cosmo/web/views/overview/index.erb +28 -0
- data/lib/cosmo/web/views/processes/_table.erb +67 -0
- data/lib/cosmo/web/views/processes/index.erb +1 -0
- data/lib/cosmo/web/views/shared/_pager.erb +22 -0
- data/lib/cosmo/web/views/shared/unavailable.erb +7 -0
- data/lib/cosmo/web/views/streams/_info.erb +28 -86
- data/lib/cosmo/web/views/streams/_list.erb +7 -0
- data/lib/cosmo/web/views/streams/_pause_banner.erb +8 -16
- data/lib/cosmo/web/views/streams/_stream.erb +52 -0
- data/lib/cosmo/web/views/streams/index.erb +3 -11
- data/lib/cosmo/web/views/streams/info.erb +3 -11
- data/lib/cosmo/web.rb +67 -33
- data/lib/cosmo.rb +38 -0
- data/sig/cosmo/api/counter.rbs +2 -3
- data/sig/cosmo/api/cron/entry.rbs +2 -1
- data/sig/cosmo/api/stats/busy.rbs +3 -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/api/stats.rbs +4 -0
- data/sig/cosmo/api/stream.rbs +2 -0
- 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 +4 -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 +60 -20
- data/lib/cosmo/sentry/auto.rb +0 -6
- data/lib/cosmo/utils/overrides.rb +0 -206
- data/lib/cosmo/utils/warnings.rb +0 -17
- data/lib/cosmo/web/controllers/actions.rb +0 -16
- data/lib/cosmo/web/views/actions/index.erb +0 -7
- data/lib/cosmo/web/views/jobs/_processes.erb +0 -131
- data/lib/cosmo/web/views/jobs/_stats.erb +0 -73
- data/lib/cosmo/web/views/jobs/_tabs.erb +0 -8
- data/lib/cosmo/web/views/jobs/busy.erb +0 -18
- data/lib/cosmo/web/views/jobs/dead.erb +0 -18
- data/lib/cosmo/web/views/jobs/enqueued.erb +0 -18
- data/lib/cosmo/web/views/jobs/scheduled.erb +0 -18
- data/lib/cosmo/web/views/streams/_stream_row.erb +0 -42
- data/lib/cosmo/web/views/streams/_table.erb +0 -25
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 40e30ee2fff77d85db1a3deedf05e040c72dca39d64da01220517d3d5ae8048b
|
|
4
|
+
data.tar.gz: 6f0e5a50448674483f09f54938336940899ea81fe46ce66da23478eeee871bac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6d57b79ee594fd7a7ace94e1e3f5d70de87b9a413b85389cddb9db51f39ed09542bd0f0a7d3c4308e7009b7a2d9997b9be0c2965efa61e259bd07baf9bfa4734
|
|
7
|
+
data.tar.gz: 8122f92e84e4176a403678225ab66b753270ab5e49334568bc3e88cfa292451bc63ea6f5383bdb458ab89f8b319a6ed33b7af471f636ffe7314978c956b6acb1
|
data/README.md
CHANGED
|
@@ -58,8 +58,6 @@ bundle exec cosmo -C config/cosmo.yml -c 20 jobs # Jobs only
|
|
|
58
58
|
bundle exec cosmo -C config/cosmo.yml -c 20 streams # Streams only
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-

|
|
62
|
-
|
|
63
61
|
## 📖 Index
|
|
64
62
|
|
|
65
63
|
- [Why?](#-why)
|
|
@@ -77,6 +75,7 @@ bundle exec cosmo -C config/cosmo.yml -c 20 streams # Streams only
|
|
|
77
75
|
- [Concurrency Limiting](#concurrency-limiting)
|
|
78
76
|
- [Custom Serializers](#custom-serializers)
|
|
79
77
|
- [Error Handling](#error-handling)
|
|
78
|
+
- [Middleware](#middleware)
|
|
80
79
|
- [Testing](#testing)
|
|
81
80
|
- [Integrations](#integrations)
|
|
82
81
|
- [CLI Reference](#-cli-reference)
|
|
@@ -166,7 +165,7 @@ nothing else to run.
|
|
|
166
165
|
gem "cosmonats"
|
|
167
166
|
```
|
|
168
167
|
|
|
169
|
-
**Requirements:** Ruby ≥ 3.1, NATS Server ([install guide](https://docs.nats.io/running-a-nats-service/introduction/installation))
|
|
168
|
+
**Requirements:** Ruby ≥ 3.1, NATS Server ≥ 2.14 with JetStream enabled ([install guide](https://docs.nats.io/running-a-nats-service/introduction/installation))
|
|
170
169
|
|
|
171
170
|
**Optional gems** — not installed with `cosmonats`, add them only for the features you use:
|
|
172
171
|
|
|
@@ -208,33 +207,39 @@ mount Cosmo::Web => "/cosmo"
|
|
|
208
207
|
map "/cosmo" { run Cosmo::Web }
|
|
209
208
|
```
|
|
210
209
|
|
|
210
|
+
> [!WARNING]
|
|
211
|
+
> `Cosmo::Web` ships with **no authentication and no CSRF protection**, yet it exposes destructive actions
|
|
212
|
+
> (retrying and deleting jobs, pausing streams, running and deleting crons). Never expose it unprotected.
|
|
211
213
|
|
|
212
|
-
|
|
214
|
+
```ruby
|
|
215
|
+
# Rails, with Devise
|
|
216
|
+
authenticate :user, ->(user) { user.admin? } do
|
|
217
|
+
mount Cosmo::Web => "/cosmo"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Any Rack app (config.ru), with HTTP Basic auth
|
|
221
|
+
map "/cosmo" do
|
|
222
|
+
use Rack::Auth::Basic, "Cosmo" do |user, password|
|
|
223
|
+
Rack::Utils.secure_compare(user, ENV.fetch("COSMO_WEB_USER")) &
|
|
224
|
+
Rack::Utils.secure_compare(password, ENV.fetch("COSMO_WEB_PASSWORD"))
|
|
225
|
+
end
|
|
226
|
+
run Cosmo::Web
|
|
227
|
+
end
|
|
228
|
+
```
|
|
213
229
|
|
|
214
|
-
### 1. Create `config/cosmo.yml`
|
|
215
230
|
|
|
216
|
-
|
|
217
|
-
|
|
231
|
+
## 🚀 Quick Start
|
|
232
|
+
|
|
233
|
+
### 1. Configure (optional)
|
|
218
234
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
default: # Stream name
|
|
222
|
-
ack_policy: explicit # Acknowledgment required for each message can be explicit, none, or all
|
|
223
|
-
max_deliver: 30 # Max retry attempts before sending to a dead stream. Safety ceiling only, keep it above every job class's own retry
|
|
224
|
-
max_ack_pending: 10 # Max messages waiting for ack, if exceeded, the server will stop delivering new messages until some are acked
|
|
225
|
-
ack_wait: 15 # Seconds to wait for ack before redelivering
|
|
226
|
-
subject: jobs.%{name}.> # Subject pattern for this consumer, %{name} replaced with stream name, becomes `jobs.default.>`
|
|
235
|
+
Nothing is required: without `config/cosmo.yml`, Cosmo runs one `default` job stream plus its own service streams.
|
|
236
|
+
To change anything, write Cosmo's defaults into your project and edit them:
|
|
227
237
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
default: # Stream name
|
|
231
|
-
storage: file # Storage type (file or memory)
|
|
232
|
-
retention: workqueue # Retention policy (limits, interest, workqueue). workqueue - deletes acked/nacked, limits - append only
|
|
233
|
-
subjects: ["jobs.%{name}.>"] # Subject pattern for this stream, %{name} replaced with stream name
|
|
234
|
-
allow_direct: true # Allow direct messages to stream (required for web UI)
|
|
238
|
+
```bash
|
|
239
|
+
bundle exec cosmo --init # writes config/cosmo.yml
|
|
235
240
|
```
|
|
236
241
|
|
|
237
|
-
### 2. Create streams in NATS (one-time
|
|
242
|
+
### 2. Create streams in NATS (one-time, idempotent)
|
|
238
243
|
|
|
239
244
|
```bash
|
|
240
245
|
bundle exec cosmo -S
|
|
@@ -293,6 +298,16 @@ ReportJob.perform_at(Time.parse("2026-01-25 10:00"), 42) # Scheduled
|
|
|
293
298
|
ReportJob.perform_sync(42) # Inline, no NATS (great for tests)
|
|
294
299
|
```
|
|
295
300
|
|
|
301
|
+
Inside `perform`, the job instance knows about the message it came from:
|
|
302
|
+
|
|
303
|
+
| Accessor | Value |
|
|
304
|
+
|----------------|-----------------------------------------------------------------------------------------|
|
|
305
|
+
| `jid` | Job id, returned by `perform_async` |
|
|
306
|
+
| `attempt` | 1-based delivery attempt |
|
|
307
|
+
| `enqueued_at` | `Time` the job was enqueued; for a cron job, when NATS fired the schedule |
|
|
308
|
+
| `scheduled_by` | Subject of the cron schedule that fired the job, `nil` for jobs enqueued any other way |
|
|
309
|
+
| `batch_id` | Id of the [batch](#batches) the job belongs to, `nil` outside a batch |
|
|
310
|
+
|
|
296
311
|
### Streams
|
|
297
312
|
|
|
298
313
|
```ruby
|
|
@@ -338,98 +353,89 @@ message.term # Permanent failure, no retry
|
|
|
338
353
|
**NATS subjects** follow a dot-separated hierarchy (`events.clicks.homepage`).
|
|
339
354
|
The `>` wildcard matches everything after that prefix. Think of subjects as topic names — flexible routing with no extra configuration.
|
|
340
355
|
|
|
341
|
-
|
|
356
|
+
**`config/cosmo.yml` is optional.** Cosmo ships its defaults in
|
|
357
|
+
[`lib/cosmo/config/cosmo.yml`](lib/cosmo/config/cosmo.yml) (`cosmo --init` copies it into your project), and your
|
|
358
|
+
file is deep-merged over them: keep only what you change. The one list that is not merged is your job streams —
|
|
359
|
+
listing any under `setup.jobs` replaces the built-in `default` stream and its consumer:
|
|
342
360
|
```yaml
|
|
343
|
-
|
|
344
|
-
concurrency: &concurrency 1 # Number of worker threads
|
|
345
|
-
max_retries: &max_retries 3 # Default max retries
|
|
346
|
-
batch_expiry: 259200 # Seconds before a Batch's tracking data expires (default: 3 days)
|
|
361
|
+
concurrency: 10
|
|
347
362
|
|
|
348
363
|
stream_config: &stream_config
|
|
349
|
-
storage: file
|
|
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
|
|
364
|
+
storage: file
|
|
365
|
+
retention: workqueue
|
|
366
|
+
duplicate_window: 120 # seconds
|
|
367
|
+
discard: new
|
|
368
|
+
allow_direct: true # required by the web UI
|
|
369
|
+
subjects: ["jobs.%{name}.>"]
|
|
356
370
|
|
|
357
371
|
consumer_config: &consumer_config
|
|
358
|
-
ack_policy: explicit
|
|
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
|
|
372
|
+
ack_policy: explicit
|
|
373
|
+
max_deliver: 30 # keep it above every job class's retry
|
|
374
|
+
max_ack_pending: 20
|
|
375
|
+
ack_wait: 60 # seconds
|
|
376
|
+
subject: jobs.%{name}.>
|
|
383
377
|
|
|
384
378
|
setup:
|
|
385
379
|
jobs:
|
|
386
|
-
critical:
|
|
387
|
-
|
|
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
|
|
380
|
+
critical: { <<: *stream_config }
|
|
381
|
+
default: { <<: *stream_config }
|
|
382
|
+
streams: # Cosmo::Stream processors are always yours to declare
|
|
383
|
+
events:
|
|
384
|
+
storage: file
|
|
404
385
|
retention: limits
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
description: Broken jobs (DLQ)
|
|
408
|
-
|
|
409
|
-
development:
|
|
410
|
-
verbose: false
|
|
411
|
-
concurrency: *concurrency
|
|
386
|
+
max_age: 86400
|
|
387
|
+
subjects: ["events.>"]
|
|
412
388
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
389
|
+
consumers:
|
|
390
|
+
jobs:
|
|
391
|
+
critical: { <<: *consumer_config, priority: 50 }
|
|
392
|
+
default: { <<: *consumer_config, priority: 15 }
|
|
393
|
+
```
|
|
394
|
+
`max_age`, `duplicate_window`, and `ack_wait` are in seconds. Without `setup.jobs`, `consumers.jobs.default` still
|
|
395
|
+
tunes the built-in consumer (e.g. just `ack_wait: 300`).
|
|
416
396
|
|
|
417
|
-
|
|
418
|
-
|
|
397
|
+
**Service streams** — the `scheduled` stream (delayed jobs, crons), the `dead` stream (dead letters), and the stats
|
|
398
|
+
counters and KV buckets are Cosmo's own: they are not part of `cosmo.yml`, and `cosmo --setup` creates them next to
|
|
399
|
+
yours. Tune them in `Cosmo.configure`:
|
|
400
|
+
```ruby
|
|
401
|
+
Cosmo.configure do |config|
|
|
402
|
+
config.replicas = 3 # every service stream and bucket, for a NATS cluster (default 1)
|
|
403
|
+
config.dead.max_age = "14d" # dead-letter retention: max_age, max_msgs (10_000), max_bytes (-1); default 7d
|
|
404
|
+
config.dead.enabled = false # drop jobs that give up instead of parking them
|
|
405
|
+
config.scheduled.enabled = false # no scheduled stream or scheduler: perform_in/perform_at and crons raise
|
|
406
|
+
config.batches.expiry = "1d" # how long batch tracking data lives (default 3d)
|
|
407
|
+
config.metrics.retention = "90d" # how long the Metrics tab's per-day numbers are kept (default 30d)
|
|
408
|
+
config.metrics.enabled = false # record nothing and hide the Metrics tab
|
|
409
|
+
end
|
|
419
410
|
```
|
|
411
|
+
Turning `scheduled` or `dead` off also hides their pages in the web UI. Stats can't be turned off — the web UI
|
|
412
|
+
depends on them.
|
|
420
413
|
|
|
421
|
-
**Programmatic:**
|
|
414
|
+
**Programmatic:** `cosmo.yml` holds streams, consumers, and process settings; `Cosmo.configure` holds what YAML can't
|
|
415
|
+
express (loggers, middleware) and Cosmo's service settings. Command-line flags (`-c`, `-t`, `-p`) win over the file.
|
|
422
416
|
```ruby
|
|
423
|
-
Cosmo
|
|
424
|
-
|
|
417
|
+
Cosmo.configure do |config|
|
|
418
|
+
config.logger = Rails.logger # any ::Logger; defaults to stdout
|
|
419
|
+
config.log_level = :debug # COSMO_LOG_LEVEL, when set, wins
|
|
420
|
+
end
|
|
425
421
|
```
|
|
422
|
+
Cosmo leaves a custom logger's formatter alone, so its lines lack Cosmo's `jid`/`elapsed` context unless you set
|
|
423
|
+
`logger.formatter = Cosmo::Logger::SimpleFormatter.new`. Trace output is dropped for loggers without a `trace` level.
|
|
426
424
|
|
|
427
425
|
**Environment variables:**
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
426
|
+
|
|
427
|
+
| Variable | Default | Description |
|
|
428
|
+
|----------------------------|--------------------------------------|-------------------------------------------------------------------------------------|
|
|
429
|
+
| `NATS_URL` | `nats://localhost:4222` | NATS server(s) to connect to |
|
|
430
|
+
| `COSMO_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error` or `fatal` |
|
|
431
|
+
| `COSMO_CLIENT_NAME` | `cosmo-<program>-<host>-<pid>` | NATS connection name, shown in `nats server report connections` and `/connz` |
|
|
432
|
+
| `COSMO_JS_TIMEOUT` | `5` | JetStream API timeout in seconds; raise it for the web UI on a busy server |
|
|
433
|
+
| `COSMO_CONNECT_TIMEOUT` | `2` | NATS TCP connect timeout in seconds |
|
|
434
|
+
| `NATS_MAX_RECONNECT_ATTEMPTS` | `-1` | Reconnect attempts before giving up on NATS; `-1` keeps trying through any outage |
|
|
435
|
+
| `COSMO_JOBS_STREAMS` | all configured | Comma-separated job streams to subscribe to, when `--streams`/`--stream` isn't given |
|
|
436
|
+
| `COSMO_JOBS_FETCH_TIMEOUT` | `0.1` | Seconds a jobs worker waits on each fetch |
|
|
437
|
+
| `COSMO_WEB_POLL_INTERVAL` | `5` | Web UI auto-refresh interval in seconds |
|
|
438
|
+
| `COSMO_WEB_CACHE_TTL` | `2` | Seconds the web UI shares a refresh between viewers; `0` turns sharing off |
|
|
433
439
|
|
|
434
440
|
|
|
435
441
|
## 🔧 Advanced Usage
|
|
@@ -536,8 +542,8 @@ end
|
|
|
536
542
|
end
|
|
537
543
|
```
|
|
538
544
|
- Batch tracking data (pending counts, callbacks, results) expires automatically after
|
|
539
|
-
`
|
|
540
|
-
- Open and finished batches — with pending/succeeded/failed counts — are listed live
|
|
545
|
+
`config.batches.expiry` (default: 3 days), set in `Cosmo.configure`.
|
|
546
|
+
- Open and finished batches — with pending/succeeded/failed counts — are listed live on the web UI's **Batches** page.
|
|
541
547
|
- Only `Cosmo::Job`-based jobs are tracked; the ActiveJob adapter doesn't currently participate in
|
|
542
548
|
batches (see [`docs/active_job.md`](docs/active_job.md)).
|
|
543
549
|
|
|
@@ -612,6 +618,69 @@ If the proc returns something non-numeric/non-positive, or raises, the default b
|
|
|
612
618
|
instead. Note: if this job class also sets `limit: { concurrency: ... }` (see above), `count`
|
|
613
619
|
includes deliveries that were turned away for lack of a free slot, not just failed attempts.
|
|
614
620
|
|
|
621
|
+
**Error handlers** receive every error Cosmo rescues — each failed job attempt, a failing stream batch, fetch and
|
|
622
|
+
scheduler errors, and rejected messages — so one hook reports them all:
|
|
623
|
+
```ruby
|
|
624
|
+
Cosmo.configure do |config|
|
|
625
|
+
config.error_handlers << ->(error, context) { Honeybadger.notify(error, context: context) }
|
|
626
|
+
end
|
|
627
|
+
```
|
|
628
|
+
`context` is a Hash with a `:source` (`:job`, `:stream`, `:fetch`, `:scheduler`, `:reject`, `:retry_in`, `:limit`)
|
|
629
|
+
and what is known there: for a job its payload (`jid`, `class`, `args`, …), `stream`, `subject`, and `attempt`; for
|
|
630
|
+
a stream batch its `stream`, `processor`, and `size`. A handler that raises is logged and skipped.
|
|
631
|
+
|
|
632
|
+
### Middleware
|
|
633
|
+
|
|
634
|
+
Every job execution on a worker runs through the server middleware chain. A middleware gets the
|
|
635
|
+
job instance, its parsed payload, and the NATS message, and yields to run the rest of the chain:
|
|
636
|
+
```ruby
|
|
637
|
+
class TimingMiddleware
|
|
638
|
+
def initialize(threshold:)
|
|
639
|
+
@threshold = threshold
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
def call(job, data, message)
|
|
643
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
644
|
+
yield
|
|
645
|
+
ensure
|
|
646
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
647
|
+
job.logger.warn("#{data[:class]} took #{elapsed.round(2)}s on #{message.metadata.stream}") if elapsed > @threshold
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
Cosmo.configure do |config|
|
|
652
|
+
config.server_middleware do |chain|
|
|
653
|
+
chain.add TimingMiddleware, threshold: 5
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
```
|
|
657
|
+
Register middleware at boot, before workers start (e.g. in a Rails initializer; loading `cosmo.yml` afterwards keeps
|
|
658
|
+
it). The chain starts with Cosmo's required middleware: `Cosmo::Middleware::Limit` (a job class's `limit:`
|
|
659
|
+
option), `Cosmo::Middleware::Busy` (the Web UI's Busy page), `Cosmo::Middleware::Totals` (processed/failed counts,
|
|
660
|
+
one per execution), and `Cosmo::Middleware::Metrics` (the Metrics tab). `add` appends innermost, after them; `prepend`, `insert_before`, `insert_after`, and `remove`
|
|
661
|
+
reorder the chain, so moving or removing the built-ins is at your own risk.
|
|
662
|
+
A middleware that raises sends the job through the usual retry/DLQ path, and one that doesn't yield skips the job
|
|
663
|
+
and acks it. To put a job back for later instead, raise `Cosmo::Job::Requeue.new(delay_in_seconds)`: the job is
|
|
664
|
+
redelivered without failing, though the redelivery still counts toward its `retry` and `max_deliver`.
|
|
665
|
+
A fresh instance is built for every job. `perform_sync` doesn't run the chain.
|
|
666
|
+
|
|
667
|
+
The **client** chain runs where a job is enqueued (`perform_async`, `perform_in`, `perform_at`, and the ActiveJob
|
|
668
|
+
adapter), around the publish. It starts empty, and a middleware gets the job class name, the payload Hash that will be
|
|
669
|
+
published, and the stream the job runs on:
|
|
670
|
+
```ruby
|
|
671
|
+
class RequestIdMiddleware
|
|
672
|
+
def call(_job_class, payload, _stream)
|
|
673
|
+
payload[:request_id] = Current.request_id
|
|
674
|
+
yield
|
|
675
|
+
end
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
Cosmo.configure do |config|
|
|
679
|
+
config.client_middleware { |chain| chain.add RequestIdMiddleware }
|
|
680
|
+
end
|
|
681
|
+
```
|
|
682
|
+
Not yielding stops the publish: `perform_async` returns `nil`, and a batch the job was joining doesn't wait for it.
|
|
683
|
+
|
|
615
684
|
### Testing
|
|
616
685
|
|
|
617
686
|
```ruby
|
|
@@ -651,11 +720,16 @@ ActiveJob::Base.queue_adapter = Cosmo::ActiveJobAdapter::Adapter.new
|
|
|
651
720
|
|
|
652
721
|
**Sentry:**
|
|
653
722
|
```ruby
|
|
654
|
-
require "cosmo/sentry
|
|
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.
|
|
@@ -803,16 +896,29 @@ sudo systemctl enable cosmo && sudo systemctl start cosmo
|
|
|
803
896
|
|
|
804
897
|
## 📊 Monitoring
|
|
805
898
|
|
|
806
|
-
**Web UI** — mount `Cosmo::Web` (see [Installation](#-installation)) for a live, htmx-powered dashboard
|
|
807
|
-
|
|
808
|
-
- **
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
899
|
+
**Web UI** — mount `Cosmo::Web` (see [Installation](#-installation)) for a live, htmx-powered dashboard with a
|
|
900
|
+
light, dark, or automatic (system) theme. Fonts and assets are bundled, so it loads nothing from a CDN:
|
|
901
|
+
- **Overview** — processed, failed, busy, enqueued, retries, scheduled and dead totals; a 7- or 30-day throughput
|
|
902
|
+
chart of successful and failed runs; a grid of every worker thread, busy ones colored by stream; the queues; and the
|
|
903
|
+
jobs running right now, longest first
|
|
904
|
+
- **Jobs** — each job queue with its size, busy jobs and redeliveries, paused and resumed in place; then busy,
|
|
905
|
+
enqueued (per queue), scheduled and dead jobs, with retry and delete for dead ones and their error and backtrace
|
|
906
|
+
- **Processes** — every live worker (idle ones included): host, pid, IP, NATS connection name, command line,
|
|
907
|
+
subscribed streams, busy/concurrency, running/quiet/stopping state, memory, NATS RTT and reconnects. Each worker
|
|
908
|
+
beats into the `_cosmoprocesses` KV bucket every 10s, and a crashed worker drops out within 60s
|
|
909
|
+
- **Streams** — every stream with its subjects, messages, size, retention and replicas, its consumers' pending,
|
|
910
|
+
ack-pending and redelivered counts, and pause/resume
|
|
813
911
|
- **Crons** — every schedule deployed in NATS, with run-now and delete
|
|
814
|
-
- **Batches** — open and finished batches with
|
|
815
|
-
-
|
|
912
|
+
- **Batches** — open and finished batches with their progress, succeeded/failed/pending counts and callbacks
|
|
913
|
+
- **Metrics** — per job class over today, 7, or 30 days: runs, failures, average execution time (successful runs), and
|
|
914
|
+
average wait from enqueue to first delivery, plus a per-day chart of runs and average execution time. Recorded by
|
|
915
|
+
`Cosmo::Middleware::Metrics`, flushed by every worker's heartbeat into the internal `_cosmometrics` counters stream;
|
|
916
|
+
turn it off with `config.metrics.enabled = false`
|
|
917
|
+
|
|
918
|
+
Each panel refreshes `COSMO_WEB_POLL_INTERVAL` seconds after its previous response, never on top of one still
|
|
919
|
+
running, backs off while refreshes fail, and pauses in a hidden tab. Identical refreshes from several viewers share a
|
|
920
|
+
single NATS read for `COSMO_WEB_CACHE_TTL` seconds, and while NATS is unreachable the UI answers at once instead of
|
|
921
|
+
holding your app server's threads.
|
|
816
922
|
|
|
817
923
|
**Structured logs:**
|
|
818
924
|
```
|
|
@@ -32,8 +32,7 @@ module Cosmo
|
|
|
32
32
|
options = { stream: stream }.merge(cosmo_opts)
|
|
33
33
|
options[:at] = timestamp if timestamp
|
|
34
34
|
|
|
35
|
-
|
|
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
|
|
@@ -58,7 +58,7 @@ module Cosmo
|
|
|
58
58
|
def publish(key, value, msg_id: nil)
|
|
59
59
|
rescued = nil
|
|
60
60
|
headers = { "Nats-Incr" => value }
|
|
61
|
-
headers[
|
|
61
|
+
headers[NATS::JetStream::Header::MSG_ID] = msg_id if msg_id
|
|
62
62
|
|
|
63
63
|
begin
|
|
64
64
|
ack = client.publish(subject(key), "", header: headers)
|
|
@@ -67,13 +67,13 @@ module Cosmo
|
|
|
67
67
|
raise if rescued
|
|
68
68
|
|
|
69
69
|
rescued = true
|
|
70
|
-
client.create_stream(STREAM_NAME,
|
|
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/entry.rb
CHANGED
|
@@ -66,17 +66,16 @@ module Cosmo
|
|
|
66
66
|
"jobs.#{@stream}.#{job_name}"
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
def
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
headers
|
|
69
|
+
# The +schedule:+ option of nats-pure's JetStream#publish that makes the template a schedule.
|
|
70
|
+
# @-shortcuts (+@daily+, +@every 5m+) travel as the cron expression, which the server understands.
|
|
71
|
+
def schedule
|
|
72
|
+
{ cron: @expression, target: target_subject, time_zone: @timezone }.compact
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Headers stored on the schedule template. NATS copies them onto every message it fires,
|
|
76
|
+
# which is how the scheduler learns where the job belongs.
|
|
77
|
+
def headers
|
|
78
|
+
{ "X-Stream" => @stream, "X-Subject" => dispatch_subject }
|
|
80
79
|
end
|
|
81
80
|
|
|
82
81
|
def job_name
|
data/lib/cosmo/api/cron.rb
CHANGED
|
@@ -32,10 +32,13 @@ module Cosmo
|
|
|
32
32
|
|
|
33
33
|
# Publish (or replace) a schedule message in NATS.
|
|
34
34
|
# @return [Hash, nil] the persisted schedule as a hash, or nil on failure
|
|
35
|
+
# @raise [SchedulingDisabledError] when scheduling is turned off
|
|
35
36
|
def upsert!(class_name: nil, stream: nil, schedule: nil, args: [], timezone: nil, name: nil)
|
|
37
|
+
raise SchedulingDisabledError unless Config.scheduled.enabled
|
|
38
|
+
|
|
36
39
|
e = Entry.new(class_name: class_name, stream: stream, expression: schedule,
|
|
37
40
|
args: args, timezone: timezone, name: name)
|
|
38
|
-
client.publish(e.schedule_subject, e.job_payload, stream: STREAM, header: e.
|
|
41
|
+
client.publish(e.schedule_subject, e.job_payload, stream: STREAM, header: e.headers, schedule: e.schedule)
|
|
39
42
|
build_from_nats(e.schedule_subject)
|
|
40
43
|
end
|
|
41
44
|
|
|
@@ -91,8 +94,8 @@ module Cosmo
|
|
|
91
94
|
{
|
|
92
95
|
class: body[:class],
|
|
93
96
|
stream: headers["X-Stream"],
|
|
94
|
-
schedule: headers[
|
|
95
|
-
timezone: headers[
|
|
97
|
+
schedule: headers[NATS::JetStream::Header::SCHEDULE],
|
|
98
|
+
timezone: headers[NATS::JetStream::Header::SCHEDULE_TIME_ZONE],
|
|
96
99
|
args: body[:args] || [],
|
|
97
100
|
name: name_from_subject(subject),
|
|
98
101
|
schedule_subject: subject,
|
data/lib/cosmo/api/job.rb
CHANGED
data/lib/cosmo/api/kv.rb
CHANGED
|
@@ -98,14 +98,14 @@ module Cosmo
|
|
|
98
98
|
results
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
-
# CAS = Compare-And-Swap: publish +value+ with a per-message
|
|
101
|
+
# CAS = Compare-And-Swap: publish +value+ with a per-message TTL of +ttl+ whole seconds (at least 1),
|
|
102
102
|
# but only if the subject's current last sequence matches +last_seq+
|
|
103
103
|
# (sent as the Nats-Expected-Last-Subject-Sequence header). Raises
|
|
104
104
|
# NATS::KeyValue::KeyWrongLastSequenceError if it doesn't match --
|
|
105
105
|
# e.g. last_seq: 0 means "only publish if nothing exists here yet".
|
|
106
106
|
def publish_cas(key, value, ttl, last_seq:)
|
|
107
|
-
headers = {
|
|
108
|
-
client.js.publish("$KV.#{@name}.#{key}", value, header: headers)
|
|
107
|
+
headers = { NATS::JetStream::Header::EXPECTED_LAST_SUBJECT_SEQUENCE => last_seq.to_s }
|
|
108
|
+
client.js.publish("$KV.#{@name}.#{key}", value, header: headers, ttl: [ttl.to_i, 1].max)
|
|
109
109
|
rescue NATS::JetStream::Error::APIError => e
|
|
110
110
|
raise NATS::KeyValue::KeyWrongLastSequenceError, e.description if e.err_code == 10_071
|
|
111
111
|
|