cosmonats 0.4.0 → 0.4.2
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 +120 -4
- data/lib/cosmo/api/kv.rb +37 -29
- data/lib/cosmo/api/stream.rb +36 -6
- data/lib/cosmo/cli.rb +1 -1
- data/lib/cosmo/client.rb +4 -2
- data/lib/cosmo/job/limit.rb +9 -5
- data/lib/cosmo/job/processor.rb +21 -9
- data/lib/cosmo/job.rb +13 -6
- data/lib/cosmo/sentry/auto.rb +6 -0
- data/lib/cosmo/sentry/job_processor_middleware.rb +66 -0
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +24 -0
- data/lib/cosmo/web/helpers/application.rb +24 -0
- data/lib/cosmo/web/views/jobs/_enqueued.erb +21 -3
- data/lib/cosmo/web/views/jobs/_stats.erb +5 -1
- data/lib/cosmo/web/views/layout.erb +1 -0
- data/sig/cosmo/api/kv.rbs +8 -0
- data/sig/cosmo/api/stream.rbs +6 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9167e7ccc35b55db0664e863eb04b007614dfc78efb7f203706b18e58ed2593f
|
|
4
|
+
data.tar.gz: 3654c4f5681440e79498b04328cd6565e646f6e9185c4705ca82aef517bf3342
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b92b7cce247a690370cfcf3f0941a30c63e35678b51228f784046ec9e04922d7632279d6f0d8f20641b168a06c5aa6f5198881b2060b65b4bad1e0abbffbf8c7
|
|
7
|
+
data.tar.gz: d005e21357274d3ef2a53601a0b7c9647d3fedef049a3ef64064b59ed1c3ee6f9c160c8304d05b5886da70c5b5227a2dabebfe3dce4e328ae62ad7bd37c4e2de
|
data/README.md
CHANGED
|
@@ -71,6 +71,13 @@ bundle exec cosmo -C config/cosmo.yml -c 20 streams # Streams only
|
|
|
71
71
|
- [Streams](#streams)
|
|
72
72
|
- [Configuration](#configuration)
|
|
73
73
|
- [Advanced Usage](#-advanced-usage)
|
|
74
|
+
- [Cron](#cron)
|
|
75
|
+
- [Priority Queues](#priority-queues)
|
|
76
|
+
- [Concurrency Limiting](#concurrency-limiting)
|
|
77
|
+
- [Custom Serializers](#custom-serializers)
|
|
78
|
+
- [Error Handling](#error-handling)
|
|
79
|
+
- [Testing](#testing)
|
|
80
|
+
- [Integrations](#integrations)
|
|
74
81
|
- [CLI Reference](#-cli-reference)
|
|
75
82
|
- [Deployment](#-deployment)
|
|
76
83
|
- [Monitoring](#-monitoring)
|
|
@@ -138,6 +145,8 @@ nothing else to run.
|
|
|
138
145
|
- **Automatic retries** — exponential backoff, configurable attempts
|
|
139
146
|
- **Dead letter queue** — capture permanently failed jobs
|
|
140
147
|
- **Job uniqueness** — prevent duplicate execution
|
|
148
|
+
- **Concurrency limits** — cap simultaneous executions per class or per key
|
|
149
|
+
- **Cron scheduling** — recurring jobs manageable live from the web UI
|
|
141
150
|
|
|
142
151
|
### 🌊 Stream Processing
|
|
143
152
|
- **Real-time event streams** — process continuous data feeds
|
|
@@ -145,6 +154,7 @@ nothing else to run.
|
|
|
145
154
|
- **Message replay** — reprocess from any point in time
|
|
146
155
|
- **Consumer groups** — load-balanced across workers
|
|
147
156
|
- **Custom serialization** — JSON, MessagePack, Protobuf
|
|
157
|
+
- **Pause / resume** — stop and restart a stream's processing without losing its position
|
|
148
158
|
|
|
149
159
|
|
|
150
160
|
## 📦 Installation
|
|
@@ -407,7 +417,48 @@ export COSMO_STREAMS_FETCH_TIMEOUT=0.1
|
|
|
407
417
|
|
|
408
418
|
## 🔧 Advanced Usage
|
|
409
419
|
|
|
410
|
-
|
|
420
|
+
### Cron
|
|
421
|
+
|
|
422
|
+
Recurring jobs, without a separate scheduler process. A schedule is just a message parked in the
|
|
423
|
+
job's own NATS stream (requires NATS Server 2.14+) — NATS fires it on the cron expression, and it
|
|
424
|
+
lands back in the stream as a regular job. Deploy it once; whatever's in NATS is exactly what runs
|
|
425
|
+
and exactly what shows up in the web UI's **Crons** tab, where each entry can be inspected, run
|
|
426
|
+
immediately, or deleted.
|
|
427
|
+
|
|
428
|
+
Declare schedules right in `config/cosmo.yml`:
|
|
429
|
+
|
|
430
|
+
```yaml
|
|
431
|
+
setup:
|
|
432
|
+
cron:
|
|
433
|
+
daily_report:
|
|
434
|
+
class: ReportJob
|
|
435
|
+
schedule: "@daily" # @-shortcuts are passed straight through to NATS
|
|
436
|
+
stream: default
|
|
437
|
+
weekday_digest:
|
|
438
|
+
class: ReportJob
|
|
439
|
+
schedule: "0 9 * * 1-5" # 6-field NATS cron (seconds first); 5-field UNIX cron is auto-normalized
|
|
440
|
+
stream: default
|
|
441
|
+
args: ["daily"]
|
|
442
|
+
timezone: America/New_York # optional, cron expressions only
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
`cosmo -C config/cosmo.yml -S` syncs it — whatever's in the file is exactly what ends up scheduled in NATS, same as streams.
|
|
446
|
+
|
|
447
|
+
Prefer to manage schedules at runtime instead? The same operations are available from Ruby:
|
|
448
|
+
|
|
449
|
+
```ruby
|
|
450
|
+
Cosmo::API::Cron.instance.upsert!(
|
|
451
|
+
class_name: "ReportJob", stream: "default", schedule: "0 9 * * 1-5",
|
|
452
|
+
args: ["daily"], timezone: "America/New_York", name: "weekday_report"
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
Cosmo::API::Cron.instance.all # every schedule currently deployed
|
|
456
|
+
Cosmo::API::Cron.instance.run_now!("cosmo.cron.default.report_job.weekday_report") # bypass the timer
|
|
457
|
+
Cosmo::API::Cron.instance.delete!("cosmo.cron.default.report_job.weekday_report") # stop future firings
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
### Priority Queues
|
|
461
|
+
|
|
411
462
|
```ruby
|
|
412
463
|
class UrgentJob
|
|
413
464
|
include Cosmo::Job
|
|
@@ -415,7 +466,30 @@ class UrgentJob
|
|
|
415
466
|
end
|
|
416
467
|
```
|
|
417
468
|
|
|
418
|
-
|
|
469
|
+
### Concurrency Limiting
|
|
470
|
+
|
|
471
|
+
```ruby
|
|
472
|
+
class ThirdPartyApiJob
|
|
473
|
+
include Cosmo::Job
|
|
474
|
+
# At most 3 instances of this job run at once, cluster-wide.
|
|
475
|
+
# Jobs that lose the race are NAK'd with a delay equal to `duration`
|
|
476
|
+
# so they aren't redelivered until a slot is guaranteed free.
|
|
477
|
+
options limit: { duration: 30, concurrency: 3 }
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
class PerAccountSyncJob
|
|
481
|
+
include Cosmo::Job
|
|
482
|
+
# Scope the cap per key instead of class-wide — e.g. one concurrent sync per account.
|
|
483
|
+
options limit: { duration: 30, concurrency: { to: 1, key: ->(account_id) { account_id } } }
|
|
484
|
+
|
|
485
|
+
def perform(account_id)
|
|
486
|
+
Account.find(account_id).sync!
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
### Custom Serializers
|
|
492
|
+
|
|
419
493
|
```ruby
|
|
420
494
|
module MessagePackSerializer
|
|
421
495
|
def self.serialize(data) = MessagePack.pack(data)
|
|
@@ -428,7 +502,8 @@ class FastStream
|
|
|
428
502
|
end
|
|
429
503
|
```
|
|
430
504
|
|
|
431
|
-
|
|
505
|
+
### Error Handling
|
|
506
|
+
|
|
432
507
|
```ruby
|
|
433
508
|
class ResilientJob
|
|
434
509
|
include Cosmo::Job
|
|
@@ -446,7 +521,8 @@ class ResilientJob
|
|
|
446
521
|
end
|
|
447
522
|
```
|
|
448
523
|
|
|
449
|
-
|
|
524
|
+
### Testing
|
|
525
|
+
|
|
450
526
|
```ruby
|
|
451
527
|
# Synchronous — no NATS needed
|
|
452
528
|
SendEmailJob.perform_sync(123, "test")
|
|
@@ -457,6 +533,40 @@ assert_kind_of String, jid
|
|
|
457
533
|
```
|
|
458
534
|
|
|
459
535
|
|
|
536
|
+
### Integrations
|
|
537
|
+
|
|
538
|
+
**ActiveJob:**
|
|
539
|
+
```ruby
|
|
540
|
+
# config/application.rb
|
|
541
|
+
config.active_job.queue_adapter = :cosmonats
|
|
542
|
+
```
|
|
543
|
+
The ActiveJob queue name maps directly to a Cosmo stream. Use `cosmo_options` for anything
|
|
544
|
+
Cosmo-specific — retries, DLQ behavior, or overriding the target stream:
|
|
545
|
+
```ruby
|
|
546
|
+
class ReportJob < ApplicationJob
|
|
547
|
+
cosmo_options retry: 5, dead: false, stream: :critical
|
|
548
|
+
|
|
549
|
+
def perform(report_id)
|
|
550
|
+
Report.find(report_id).generate!
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
```
|
|
554
|
+
Inside a Rails app this is wired up automatically by the bundled Railtie — it registers the
|
|
555
|
+
adapter and loads `config/cosmo.yml` if present. Outside Rails:
|
|
556
|
+
```ruby
|
|
557
|
+
require "cosmo/active_job"
|
|
558
|
+
ActiveJob::Base.queue_adapter = Cosmo::ActiveJobAdapter::Adapter.new
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
**Sentry:**
|
|
562
|
+
```ruby
|
|
563
|
+
require "cosmo/sentry/auto"
|
|
564
|
+
```
|
|
565
|
+
Wraps every job execution in a Sentry transaction (`queue.cosmonats`) and captures unhandled
|
|
566
|
+
exceptions with the job's id, stream, subject, and retry count attached as context — no other
|
|
567
|
+
setup beyond having `sentry-ruby` initialized.
|
|
568
|
+
|
|
569
|
+
|
|
460
570
|
## 🖥️ CLI Reference
|
|
461
571
|
|
|
462
572
|
```bash
|
|
@@ -542,6 +652,12 @@ sudo systemctl enable cosmo && sudo systemctl start cosmo
|
|
|
542
652
|
|
|
543
653
|
## 📊 Monitoring
|
|
544
654
|
|
|
655
|
+
**Web UI** — mount `Cosmo::Web` (see [Installation](#-installation)) for a live, htmx-powered dashboard:
|
|
656
|
+
- **Jobs** — enqueued, scheduled, busy, and dead views, with per-job retry and delete
|
|
657
|
+
- **Streams** — per-stream state (messages, bytes, consumers) with pause/resume
|
|
658
|
+
- **Crons** — every schedule deployed in NATS, with run-now and delete
|
|
659
|
+
- Summary counters (processed / failed / busy / enqueued / retries / scheduled / dead) backed by a NATS KV counter, no separate metrics store needed
|
|
660
|
+
|
|
545
661
|
**Structured logs:**
|
|
546
662
|
```
|
|
547
663
|
2026-01-23T10:15:30.123Z INFO pid=12345 tid=abc jid=def: start
|
data/lib/cosmo/api/kv.rb
CHANGED
|
@@ -8,39 +8,15 @@ module Cosmo
|
|
|
8
8
|
def initialize(name, options = nil)
|
|
9
9
|
@name = name
|
|
10
10
|
@options = Hash(options)
|
|
11
|
-
@kv =
|
|
11
|
+
@kv = client.kv(@name, **@options)
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
def set(key, value, ttl: nil)
|
|
14
|
+
def set(key, value, ttl: nil)
|
|
15
15
|
return kv.put(key, value.to_s) unless ttl
|
|
16
16
|
|
|
17
|
-
# Pass ttl: (seconds) to set
|
|
17
|
+
# Pass ttl: (seconds) to set per-message expiry.
|
|
18
18
|
# Raises `NATS::KeyValue::KeyWrongLastSequenceError` when the key is live.
|
|
19
|
-
|
|
20
|
-
value = value.to_s
|
|
21
|
-
put = lambda do |last_seq:|
|
|
22
|
-
headers = { "Nats-Expected-Last-Subject-Sequence" => last_seq.to_s, "Nats-TTL" => "#{ttl.to_i}s" }
|
|
23
|
-
Client.instance.js.publish("$KV.#{@name}.#{key}", value, header: headers)
|
|
24
|
-
rescue NATS::JetStream::Error::APIError => e
|
|
25
|
-
raise NATS::KeyValue::KeyWrongLastSequenceError, e.description if e.err_code == 10_071
|
|
26
|
-
|
|
27
|
-
raise
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
put.call(last_seq: 0)
|
|
31
|
-
kv.send(:_get, key) # fetch the created entry to get its revision
|
|
32
|
-
rescue NATS::KeyValue::KeyWrongLastSequenceError
|
|
33
|
-
# `kv.get` converts KeyDeletedError → KeyNotFoundError, hiding tombstone info.
|
|
34
|
-
# Use private _get instead — it raises KeyDeletedError with the entry's revision
|
|
35
|
-
begin
|
|
36
|
-
kv.send(:_get, key)
|
|
37
|
-
rescue NATS::KeyValue::KeyDeletedError => e
|
|
38
|
-
put.call(last_seq: e.entry.revision)
|
|
39
|
-
return kv.send(:_get, key)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
raise
|
|
43
|
-
end
|
|
19
|
+
publish_cas(key, value.to_s, ttl, last_seq: 0).seq
|
|
44
20
|
end
|
|
45
21
|
|
|
46
22
|
def get(key)
|
|
@@ -49,6 +25,9 @@ module Cosmo
|
|
|
49
25
|
# nop
|
|
50
26
|
end
|
|
51
27
|
|
|
28
|
+
# Writes a KV-Operation tombstone. On a ttl-bearing bucket this leaves the
|
|
29
|
+
# subject occupied, so a subsequent #set(ttl:) CAS with last_seq: 0 will
|
|
30
|
+
# keep failing -- use #erase on those buckets instead.
|
|
52
31
|
def delete(key)
|
|
53
32
|
kv.delete(key)
|
|
54
33
|
end
|
|
@@ -66,12 +45,21 @@ module Cosmo
|
|
|
66
45
|
results
|
|
67
46
|
end
|
|
68
47
|
|
|
48
|
+
# Writes a KV-Operation tombstone (same issue as #delete on ttl buckets).
|
|
69
49
|
def purge(key)
|
|
70
50
|
kv.purge(key)
|
|
71
51
|
end
|
|
72
52
|
|
|
53
|
+
# Removes +key+ leaving no trace at all -- unlike #delete/#purge, which
|
|
54
|
+
# write a KV-Operation tombstone message. Mirrors how per-message
|
|
55
|
+
# Nats-TTL expiry removes a key, so callers never have to distinguish
|
|
56
|
+
# "deleted" from "TTL-expired" on read.
|
|
57
|
+
def erase(key)
|
|
58
|
+
client.purge("KV_#{@name}", "$KV.#{@name}.#{key}")
|
|
59
|
+
end
|
|
60
|
+
|
|
73
61
|
def clean
|
|
74
|
-
|
|
62
|
+
client.purge("KV_#{@name}", ">")
|
|
75
63
|
end
|
|
76
64
|
|
|
77
65
|
def count
|
|
@@ -80,6 +68,26 @@ module Cosmo
|
|
|
80
68
|
0
|
|
81
69
|
end
|
|
82
70
|
alias size count
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# CAS = Compare-And-Swap: publish +value+ with a per-message Nats-TTL,
|
|
75
|
+
# but only if the subject's current last sequence matches +last_seq+
|
|
76
|
+
# (sent as the Nats-Expected-Last-Subject-Sequence header). Raises
|
|
77
|
+
# NATS::KeyValue::KeyWrongLastSequenceError if it doesn't match --
|
|
78
|
+
# e.g. last_seq: 0 means "only publish if nothing exists here yet".
|
|
79
|
+
def publish_cas(key, value, ttl, last_seq:)
|
|
80
|
+
headers = { "Nats-Expected-Last-Subject-Sequence" => last_seq.to_s, "Nats-TTL" => "#{ttl.to_i}s" }
|
|
81
|
+
client.js.publish("$KV.#{@name}.#{key}", value, header: headers)
|
|
82
|
+
rescue NATS::JetStream::Error::APIError => e
|
|
83
|
+
raise NATS::KeyValue::KeyWrongLastSequenceError, e.description if e.err_code == 10_071
|
|
84
|
+
|
|
85
|
+
raise
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def client
|
|
89
|
+
Client.instance
|
|
90
|
+
end
|
|
83
91
|
end
|
|
84
92
|
end
|
|
85
93
|
end
|
data/lib/cosmo/api/stream.rb
CHANGED
|
@@ -45,21 +45,24 @@ module Cosmo
|
|
|
45
45
|
|
|
46
46
|
def retries
|
|
47
47
|
client.list_consumers(name).sum { _1["num_redelivered"].to_i }
|
|
48
|
+
rescue NATS::Error
|
|
49
|
+
0
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
def each
|
|
51
53
|
return if total.zero?
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
current
|
|
55
|
-
last = state.last_seq.to_i
|
|
55
|
+
candidates = {}
|
|
56
|
+
current, last, subjects = scan_range
|
|
56
57
|
|
|
57
58
|
loop do
|
|
58
59
|
break if current > last
|
|
59
60
|
|
|
60
|
-
job =
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
subject, job = next_candidate(subjects, candidates, current)
|
|
62
|
+
break unless job
|
|
63
|
+
|
|
64
|
+
candidates.delete(subject)
|
|
65
|
+
current = job.seq.to_i + 1
|
|
63
66
|
|
|
64
67
|
yield job
|
|
65
68
|
end
|
|
@@ -120,6 +123,33 @@ module Cosmo
|
|
|
120
123
|
|
|
121
124
|
private
|
|
122
125
|
|
|
126
|
+
def scan_range
|
|
127
|
+
data = info
|
|
128
|
+
state = data[:state]
|
|
129
|
+
current = @offset || state.first_seq.to_i
|
|
130
|
+
subjects = Array(data[:config].subjects).reject { _1.start_with?(Cron::Entry::SUBJECT_PREFIX) }
|
|
131
|
+
[current, state.last_seq.to_i, subjects]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Lowest-seq message at or after current across all job subject filters,
|
|
135
|
+
# caching each subject's next candidate so it isn't re-queried every step.
|
|
136
|
+
def next_candidate(subjects, candidates, current)
|
|
137
|
+
subjects.each do |subject|
|
|
138
|
+
next if candidates.key?(subject)
|
|
139
|
+
|
|
140
|
+
candidates[subject] = next_message(subject, current)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
candidates.compact.min_by { |_, msg| msg.seq.to_i }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Jump straight to the next message on the subject after seq, skipping any acked/deleted gaps
|
|
147
|
+
def next_message(subject, seq)
|
|
148
|
+
Job.new(name, client.get_message(name, next: true, seq: seq, subject: subject, direct: true))
|
|
149
|
+
rescue NATS::JetStream::Error::NotFound
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
123
153
|
def client
|
|
124
154
|
self.class.client
|
|
125
155
|
end
|
data/lib/cosmo/cli.rb
CHANGED
|
@@ -124,7 +124,7 @@ module Cosmo
|
|
|
124
124
|
class_name = entry.delete(:class)
|
|
125
125
|
API::Cron.instance.upsert!(**entry, name: name, class_name: class_name)
|
|
126
126
|
sum + 1
|
|
127
|
-
end
|
|
127
|
+
end.to_i
|
|
128
128
|
|
|
129
129
|
puts "Cron sync complete: #{schedules} schedule(s) registered" unless schedules.zero?
|
|
130
130
|
puts "Cosmo streams#{" and cron schedules" unless schedules.zero?} set up successfully."
|
data/lib/cosmo/client.rb
CHANGED
|
@@ -69,6 +69,8 @@ module Cosmo
|
|
|
69
69
|
return [] if data.nil? || data["streams"].nil?
|
|
70
70
|
|
|
71
71
|
data["streams"]
|
|
72
|
+
rescue NATS::Error
|
|
73
|
+
[]
|
|
72
74
|
end
|
|
73
75
|
|
|
74
76
|
def pause_stream(name)
|
|
@@ -101,8 +103,8 @@ module Cosmo
|
|
|
101
103
|
js.consumer_info(stream_name, consumer_name)
|
|
102
104
|
end
|
|
103
105
|
|
|
104
|
-
def get_message(
|
|
105
|
-
js.get_msg(
|
|
106
|
+
def get_message(stream_name, **options)
|
|
107
|
+
js.get_msg(stream_name, **options)
|
|
106
108
|
end
|
|
107
109
|
|
|
108
110
|
def delete_message(name, seq)
|
data/lib/cosmo/job/limit.rb
CHANGED
|
@@ -9,8 +9,9 @@ module Cosmo
|
|
|
9
9
|
#
|
|
10
10
|
# Acquiring a slot is a single atomic `set` (CAS with last-revision=0).
|
|
11
11
|
# Only one worker can win a given slot; losers try the next number.
|
|
12
|
-
# When a job finishes the slot is
|
|
13
|
-
# expires it automatically via the per-message Nats-TTL header.
|
|
12
|
+
# When a job finishes, the slot is erased; if the worker crashes, NATS
|
|
13
|
+
# expires it automatically via the per-message Nats-TTL header. Both
|
|
14
|
+
# paths leave the slot equally empty -- no tombstone, no delete marker.
|
|
14
15
|
class Limit
|
|
15
16
|
BUCKET = "cosmo_jobs_limits"
|
|
16
17
|
|
|
@@ -40,11 +41,14 @@ module Cosmo
|
|
|
40
41
|
nil # all slots occupied
|
|
41
42
|
end
|
|
42
43
|
|
|
43
|
-
# Release a previously acquired slot.
|
|
44
|
+
# Release a previously acquired slot. Erases the slot entirely (no
|
|
45
|
+
# tombstone left behind) so a released slot looks identical to one
|
|
46
|
+
# reclaimed by Nats-TTL expiry -- callers never have to special-case a
|
|
47
|
+
# delete marker.
|
|
44
48
|
def release(slot)
|
|
45
|
-
@kv.
|
|
49
|
+
@kv.erase(slot)
|
|
46
50
|
rescue NATS::Error
|
|
47
|
-
# best effort — slot TTL will reclaim it if
|
|
51
|
+
# best effort — slot TTL will reclaim it if erase fails
|
|
48
52
|
end
|
|
49
53
|
end
|
|
50
54
|
end
|
data/lib/cosmo/job/processor.rb
CHANGED
|
@@ -79,13 +79,10 @@ module Cosmo
|
|
|
79
79
|
sw = stopwatch
|
|
80
80
|
Logger.with(jid: data[:jid])
|
|
81
81
|
Logger.info "start"
|
|
82
|
-
|
|
83
|
-
instance.jid = data[:jid]
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
else
|
|
87
|
-
instance.perform(*data[:args])
|
|
88
|
-
end
|
|
82
|
+
|
|
83
|
+
instance = worker_class.new.tap { |w| w.jid = data[:jid] }
|
|
84
|
+
perform_job(instance, data: data, message: message, duration: duration)
|
|
85
|
+
|
|
89
86
|
message.ack
|
|
90
87
|
Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "done" }
|
|
91
88
|
true
|
|
@@ -110,7 +107,7 @@ module Cosmo
|
|
|
110
107
|
|
|
111
108
|
# Tries to acquire a concurrency slot for the job.
|
|
112
109
|
# Returns the slot key (String) on success, or false if all slots are
|
|
113
|
-
# taken (message is NAK'd with a delay
|
|
110
|
+
# taken (a message is NAK'd with a delay of +retry_in+ before returning
|
|
114
111
|
def acquire_concurrency_slot(worker_class, message, data)
|
|
115
112
|
options = worker_class.concurrency_options
|
|
116
113
|
key = worker_class.concurrency_key(data[:args])
|
|
@@ -118,7 +115,7 @@ module Cosmo
|
|
|
118
115
|
slot = Limit.instance.acquire(key, jid: data[:jid], limit: options[:limit], duration: options[:duration])
|
|
119
116
|
return slot if slot
|
|
120
117
|
|
|
121
|
-
message.nak(delay: options[:
|
|
118
|
+
message.nak(delay: options[:retry_in] * Config::NANO)
|
|
122
119
|
Logger.debug "concurrency limit reached for #{data[:class]}, re-queueing back #{data[:jid]}"
|
|
123
120
|
false
|
|
124
121
|
rescue NATS::Error => e
|
|
@@ -190,6 +187,21 @@ module Cosmo
|
|
|
190
187
|
API::Counter.instance.with(&block)
|
|
191
188
|
end
|
|
192
189
|
end
|
|
190
|
+
|
|
191
|
+
# @param job_instance [Cosmo::Job]
|
|
192
|
+
# @param data [Hash]
|
|
193
|
+
# @param message [NATS::Msg]
|
|
194
|
+
# @param duration [Float, nil]
|
|
195
|
+
#
|
|
196
|
+
# rubocop:disable Lint/UnusedMethodArgument
|
|
197
|
+
def perform_job(job_instance, data:, message:, duration: nil)
|
|
198
|
+
if duration
|
|
199
|
+
Timeout.timeout(duration) { job_instance.perform(*data[:args]) }
|
|
200
|
+
else
|
|
201
|
+
job_instance.perform(*data[:args])
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
# rubocop:enable Lint/UnusedMethodArgument
|
|
193
205
|
end
|
|
194
206
|
end
|
|
195
207
|
end
|
data/lib/cosmo/job.rb
CHANGED
|
@@ -19,14 +19,19 @@ module Cosmo
|
|
|
19
19
|
# limit: { duration: 30 }
|
|
20
20
|
# limit: { duration: 30, concurrency: 3 }
|
|
21
21
|
# limit: { duration: 30, concurrency: { to: 3, key: ->(id) { id } } }
|
|
22
|
+
# limit: { duration: 30, concurrency: 3, retry_in: 5 }
|
|
22
23
|
#
|
|
23
24
|
# @option config [Integer] :"limit[:duration]" hard execution timeout in seconds. The job thread is
|
|
24
25
|
# killed after this many seconds and counts as a failed attempt (retried with exponential backoff,
|
|
25
26
|
# moved to DLQ after retries exhausted).
|
|
26
27
|
# @option config [Integer, Hash] :"limit[:concurrency]" caps how many instances run at once across all
|
|
27
|
-
# workers. Jobs that cannot acquire a slot are NAK'd
|
|
28
|
-
#
|
|
28
|
+
# workers. Jobs that cannot acquire a slot are NAK'd (see +retry_in+) so they are not re-delivered until
|
|
29
|
+
# the slot is likely free. Requires +duration+.
|
|
29
30
|
# Pass an Integer for a class-wide cap, or <tt>{ to: N, key: ->(args) {} }</tt> to scope per key.
|
|
31
|
+
# @option config [Integer] :"limit[:retry_in]" seconds to wait before NATS redelivers a job that was
|
|
32
|
+
# NAK'd for lack of a concurrency slot (default: half of +duration+). Counts against the same delivery
|
|
33
|
+
# counter as any other retry -- a job stuck behind the concurrency limit for enough consecutive
|
|
34
|
+
# attempts is dropped/DLQ'd exactly like one that keeps failing outright.
|
|
30
35
|
def options(**config)
|
|
31
36
|
if config[:limit] && config.dig(:limit, :concurrency) && !config.dig(:limit, :duration).to_i.positive?
|
|
32
37
|
raise ArgumentError, "limit: duration is required when concurrency is set"
|
|
@@ -41,15 +46,17 @@ module Cosmo
|
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
# Returns a normalized concurrency config hash, or +nil+ when not configured.
|
|
44
|
-
# Always contains +:limit+, +:key+, and +:
|
|
49
|
+
# Always contains +:limit+, +:key+, +:duration+, and +:retry_in+.
|
|
45
50
|
def concurrency_options
|
|
46
51
|
value = default_options.dig(:limit, :concurrency)
|
|
47
|
-
duration = default_options.dig(:limit, :duration).to_i
|
|
48
52
|
return unless value
|
|
49
53
|
|
|
54
|
+
duration = default_options.dig(:limit, :duration).to_i
|
|
55
|
+
retry_in = default_options.dig(:limit, :retry_in)&.to_i || (duration / 2)
|
|
56
|
+
|
|
50
57
|
case value
|
|
51
|
-
when Integer then { limit: value, key: nil, duration: duration }
|
|
52
|
-
when Hash then { limit: value.fetch(:to), key: value[:key], duration: duration }
|
|
58
|
+
when Integer then { limit: value, key: nil, duration: duration, retry_in: retry_in }
|
|
59
|
+
when Hash then { limit: value.fetch(:to), key: value[:key], duration: duration, retry_in: retry_in }
|
|
53
60
|
end
|
|
54
61
|
end
|
|
55
62
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
4
|
+
module Cosmo
|
|
5
|
+
module Sentry
|
|
6
|
+
module JobProcessorMiddleware
|
|
7
|
+
NAME_PREFIX = "Cosmonats"
|
|
8
|
+
OP_NAME = "queue.cosmonats"
|
|
9
|
+
SPAN_ORIGIN = "auto.queue.cosmonats"
|
|
10
|
+
STATUS_OK = 200
|
|
11
|
+
STATUS_FAIL = 500
|
|
12
|
+
|
|
13
|
+
# @param job_instance [Cosmo::Job]
|
|
14
|
+
# @param data [Hash]
|
|
15
|
+
# @param message [NATS::Msg]
|
|
16
|
+
# @param duration [Float, nil]
|
|
17
|
+
def perform_job(job_instance, data:, message:, duration: nil)
|
|
18
|
+
unless ::Sentry.initialized?
|
|
19
|
+
super
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
scope = ::Sentry.get_current_scope
|
|
24
|
+
transaction_name = "#{NAME_PREFIX}/#{job_instance.class.name}"
|
|
25
|
+
scope.set_transaction_name(transaction_name, source: :task)
|
|
26
|
+
|
|
27
|
+
transaction = ::Sentry.start_transaction(
|
|
28
|
+
name: scope.transaction_name,
|
|
29
|
+
source: scope.transaction_source,
|
|
30
|
+
op: OP_NAME,
|
|
31
|
+
origin: SPAN_ORIGIN
|
|
32
|
+
)
|
|
33
|
+
transaction&.set_data("messaging.message.id", data[:jid])
|
|
34
|
+
transaction&.set_data("messaging.destination.name", "#{message.metadata.stream}:#{message.subject}")
|
|
35
|
+
transaction&.set_data("messaging.message.retry.count", data[:retry] || 0)
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
super
|
|
39
|
+
|
|
40
|
+
transaction&.set_http_status(STATUS_OK)
|
|
41
|
+
transaction&.finish
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
::Sentry.capture_exception(
|
|
44
|
+
e,
|
|
45
|
+
contexts: {
|
|
46
|
+
cosmonats: data.merge(
|
|
47
|
+
nats_stream: message.metadata.stream,
|
|
48
|
+
nats_subject: message.subject,
|
|
49
|
+
timeout_duration: duration
|
|
50
|
+
)
|
|
51
|
+
},
|
|
52
|
+
hint: {
|
|
53
|
+
background: true,
|
|
54
|
+
integration: "cosmonats"
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
transaction&.set_http_status(STATUS_FAIL)
|
|
58
|
+
transaction&.finish
|
|
59
|
+
|
|
60
|
+
raise e
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
data/lib/cosmo/version.rb
CHANGED
|
@@ -489,6 +489,13 @@ time {
|
|
|
489
489
|
cursor: default;
|
|
490
490
|
pointer-events: none;
|
|
491
491
|
}
|
|
492
|
+
.pagination .btn-primary.btn-disabled {
|
|
493
|
+
opacity: 1;
|
|
494
|
+
}
|
|
495
|
+
.pagination-gap {
|
|
496
|
+
color: var(--color-text-light);
|
|
497
|
+
padding: 0 var(--space-1-2);
|
|
498
|
+
}
|
|
492
499
|
|
|
493
500
|
/* ── Actions ───────────────────────────────────────────────────────────── */
|
|
494
501
|
.actions-form { display: flex; flex-direction: column; gap: var(--space-1-2); }
|
|
@@ -509,6 +516,23 @@ details code { display: block; max-width: 400px; white-space: pre-wrap; }
|
|
|
509
516
|
.htmx-request .htmx-indicator,
|
|
510
517
|
.htmx-request.htmx-indicator { opacity: 1; }
|
|
511
518
|
|
|
519
|
+
#global-spinner {
|
|
520
|
+
position: fixed;
|
|
521
|
+
top: var(--space-2x);
|
|
522
|
+
right: var(--space-2x);
|
|
523
|
+
width: 24px;
|
|
524
|
+
height: 24px;
|
|
525
|
+
border: 3px solid var(--color-border);
|
|
526
|
+
border-top-color: var(--color-primary);
|
|
527
|
+
border-radius: 50%;
|
|
528
|
+
animation: spin 0.6s linear infinite;
|
|
529
|
+
z-index: 100;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
@keyframes spin {
|
|
533
|
+
to { transform: rotate(360deg); }
|
|
534
|
+
}
|
|
535
|
+
|
|
512
536
|
/* ── Responsive ────────────────────────────────────────────────────────── */
|
|
513
537
|
@media (max-width: 768px) {
|
|
514
538
|
.nav { flex-wrap: wrap; }
|
|
@@ -67,6 +67,21 @@ module Cosmo
|
|
|
67
67
|
Rack::Utils.escape(value.to_s)
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
+
# Build the list of page numbers to render around the current page, with
|
|
71
|
+
# `:gap` markers where numbers are skipped.
|
|
72
|
+
# pages(5, 20) # => [1, :gap, 3, 4, 5, 6, 7, :gap, 20]
|
|
73
|
+
def pages(page, total_pages, window: 2)
|
|
74
|
+
return [] if total_pages <= 1
|
|
75
|
+
|
|
76
|
+
previous = nil
|
|
77
|
+
candidates = ([1, total_pages] + ((page - window)..(page + window)).to_a).grep(1..total_pages).uniq.sort
|
|
78
|
+
candidates.each_with_object([]) do |p, result|
|
|
79
|
+
pagination_fill_gap(result, previous, p)
|
|
80
|
+
result << p
|
|
81
|
+
previous = p
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
70
85
|
def current_page?(path)
|
|
71
86
|
request_path = @request.path_info
|
|
72
87
|
request_path = "/" if request_path.empty?
|
|
@@ -85,6 +100,15 @@ module Cosmo
|
|
|
85
100
|
referrer_path = "/" if referrer_path.empty?
|
|
86
101
|
referrer_path == path
|
|
87
102
|
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def pagination_fill_gap(result, previous, page)
|
|
107
|
+
return unless previous
|
|
108
|
+
|
|
109
|
+
result << (previous + 1) if page - previous == 2
|
|
110
|
+
result << :gap if page - previous > 2
|
|
111
|
+
end
|
|
88
112
|
end
|
|
89
113
|
end
|
|
90
114
|
end
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
hx-target="#content"
|
|
6
6
|
hx-swap="innerHTML"
|
|
7
7
|
hx-push-url="true"
|
|
8
|
+
hx-indicator="#global-spinner"
|
|
8
9
|
class="btn <%= "btn-primary" if @stream_name.to_s == stream_name.to_s %>">
|
|
9
10
|
<%= h(stream_name) %>
|
|
10
11
|
</button>
|
|
@@ -63,20 +64,37 @@
|
|
|
63
64
|
<% if @total_pages > 1 -%>
|
|
64
65
|
<div class="pagination">
|
|
65
66
|
<% if @page > 1 -%>
|
|
66
|
-
<a hx-get="<%= url_for('/jobs/
|
|
67
|
+
<a hx-get="<%= url_for('/jobs/enqueued', stream_name: @stream_name, page: @page - 1, limit: @limit) %>"
|
|
67
68
|
hx-target="#enqueued-poller"
|
|
68
69
|
hx-swap="outerHTML"
|
|
70
|
+
hx-push-url="true"
|
|
71
|
+
hx-indicator="#global-spinner"
|
|
69
72
|
class="btn">← Prev</a>
|
|
70
73
|
<% else -%>
|
|
71
74
|
<span class="btn btn-disabled">← Prev</span>
|
|
72
75
|
<% end -%>
|
|
73
76
|
|
|
74
|
-
|
|
77
|
+
<% pages(@page, @total_pages).each do |p| -%>
|
|
78
|
+
<% if p == :gap -%>
|
|
79
|
+
<span class="pagination-gap">…</span>
|
|
80
|
+
<% elsif p == @page -%>
|
|
81
|
+
<span class="btn btn-primary btn-disabled"><%= p %></span>
|
|
82
|
+
<% else -%>
|
|
83
|
+
<a hx-get="<%= url_for('/jobs/enqueued', stream_name: @stream_name, page: p, limit: @limit) %>"
|
|
84
|
+
hx-target="#enqueued-poller"
|
|
85
|
+
hx-swap="outerHTML"
|
|
86
|
+
hx-push-url="true"
|
|
87
|
+
hx-indicator="#global-spinner"
|
|
88
|
+
class="btn"><%= p %></a>
|
|
89
|
+
<% end -%>
|
|
90
|
+
<% end -%>
|
|
75
91
|
|
|
76
92
|
<% if @page < @total_pages -%>
|
|
77
|
-
<a hx-get="<%= url_for('/jobs/
|
|
93
|
+
<a hx-get="<%= url_for('/jobs/enqueued', stream_name: @stream_name, page: @page + 1, limit: @limit) %>"
|
|
78
94
|
hx-target="#enqueued-poller"
|
|
79
95
|
hx-swap="outerHTML"
|
|
96
|
+
hx-push-url="true"
|
|
97
|
+
hx-indicator="#global-spinner"
|
|
80
98
|
class="btn">Next →</a>
|
|
81
99
|
<% else -%>
|
|
82
100
|
<span class="btn btn-disabled">Next →</span>
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
<a href="<%= url_for('/jobs/busy') %>"
|
|
20
20
|
hx-get="<%= url_for('/jobs/busy') %>"
|
|
21
21
|
hx-target="#content"
|
|
22
|
-
hx-push-url="true"
|
|
22
|
+
hx-push-url="true"
|
|
23
|
+
hx-indicator="#global-spinner">⏳ Busy</a>
|
|
23
24
|
</p>
|
|
24
25
|
</article>
|
|
25
26
|
<article class="stat-card">
|
|
@@ -29,6 +30,7 @@
|
|
|
29
30
|
hx-get="<%= url_for('/jobs/enqueued') %>"
|
|
30
31
|
hx-target="#content"
|
|
31
32
|
hx-push-url="true"
|
|
33
|
+
hx-indicator="#global-spinner"
|
|
32
34
|
class="<%= 'active' if referrer?('/jobs/enqueued') %>">📬 Enqueued</a>
|
|
33
35
|
</p>
|
|
34
36
|
</article>
|
|
@@ -39,6 +41,7 @@
|
|
|
39
41
|
hx-get="<%= url_for('/jobs/scheduled') %>"
|
|
40
42
|
hx-target="#content"
|
|
41
43
|
hx-push-url="true"
|
|
44
|
+
hx-indicator="#global-spinner"
|
|
42
45
|
class="<%= 'active' if referrer?('/jobs/scheduled') %>">⏰ Scheduled</a>
|
|
43
46
|
</p>
|
|
44
47
|
</article>
|
|
@@ -49,6 +52,7 @@
|
|
|
49
52
|
hx-get="<%= url_for('/jobs/dead') %>"
|
|
50
53
|
hx-target="#content"
|
|
51
54
|
hx-push-url="true"
|
|
55
|
+
hx-indicator="#global-spinner"
|
|
52
56
|
class="<%= 'active' if referrer?('/jobs/dead') %>">💀 Dead</a>
|
|
53
57
|
</p>
|
|
54
58
|
</article>
|
data/sig/cosmo/api/kv.rbs
CHANGED
|
@@ -19,10 +19,18 @@ module Cosmo
|
|
|
19
19
|
|
|
20
20
|
def purge: (::String | ::Integer key) -> untyped
|
|
21
21
|
|
|
22
|
+
def erase: (::String | ::Integer key) -> ::Integer?
|
|
23
|
+
|
|
22
24
|
def clean: () -> untyped
|
|
23
25
|
|
|
24
26
|
def count: () -> ::Integer
|
|
25
27
|
alias size count
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def publish_cas: (::String | ::Integer key, ::String value, ::Integer? ttl, last_seq: ::Integer) -> untyped
|
|
32
|
+
|
|
33
|
+
def client: () -> Client
|
|
26
34
|
end
|
|
27
35
|
end
|
|
28
36
|
end
|
data/sig/cosmo/api/stream.rbs
CHANGED
|
@@ -44,6 +44,12 @@ module Cosmo
|
|
|
44
44
|
|
|
45
45
|
private
|
|
46
46
|
|
|
47
|
+
def scan_range: () -> [::Integer, ::Integer, Array[::String]]
|
|
48
|
+
|
|
49
|
+
def next_candidate: (Array[::String] subjects, Hash[::String, Job?] candidates, ::Integer current) -> [::String, Job]?
|
|
50
|
+
|
|
51
|
+
def next_message: (::String subject, ::Integer seq) -> Job?
|
|
52
|
+
|
|
47
53
|
def client: () -> Client
|
|
48
54
|
end
|
|
49
55
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cosmonats
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Vorotilin
|
|
@@ -88,6 +88,8 @@ files:
|
|
|
88
88
|
- lib/cosmo/processor.rb
|
|
89
89
|
- lib/cosmo/publisher.rb
|
|
90
90
|
- lib/cosmo/railtie.rb
|
|
91
|
+
- lib/cosmo/sentry/auto.rb
|
|
92
|
+
- lib/cosmo/sentry/job_processor_middleware.rb
|
|
91
93
|
- lib/cosmo/stream.rb
|
|
92
94
|
- lib/cosmo/stream/data.rb
|
|
93
95
|
- lib/cosmo/stream/message.rb
|
|
@@ -197,7 +199,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
197
199
|
- !ruby/object:Gem::Version
|
|
198
200
|
version: '0'
|
|
199
201
|
requirements: []
|
|
200
|
-
rubygems_version: 4.0.
|
|
202
|
+
rubygems_version: 4.0.16
|
|
201
203
|
specification_version: 4
|
|
202
204
|
summary: Lightweight background and stream processing
|
|
203
205
|
test_files: []
|