cosmonats 0.4.1 → 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/job/limit.rb +9 -5
- data/lib/cosmo/job/processor.rb +2 -2
- data/lib/cosmo/job.rb +13 -6
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +7 -0
- data/lib/cosmo/web/helpers/application.rb +24 -0
- data/lib/cosmo/web/views/jobs/_enqueued.erb +18 -3
- data/sig/cosmo/api/kv.rbs +8 -0
- metadata +1 -1
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/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
|
@@ -107,7 +107,7 @@ module Cosmo
|
|
|
107
107
|
|
|
108
108
|
# Tries to acquire a concurrency slot for the job.
|
|
109
109
|
# Returns the slot key (String) on success, or false if all slots are
|
|
110
|
-
# taken (message is NAK'd with a delay
|
|
110
|
+
# taken (a message is NAK'd with a delay of +retry_in+ before returning
|
|
111
111
|
def acquire_concurrency_slot(worker_class, message, data)
|
|
112
112
|
options = worker_class.concurrency_options
|
|
113
113
|
key = worker_class.concurrency_key(data[:args])
|
|
@@ -115,7 +115,7 @@ module Cosmo
|
|
|
115
115
|
slot = Limit.instance.acquire(key, jid: data[:jid], limit: options[:limit], duration: options[:duration])
|
|
116
116
|
return slot if slot
|
|
117
117
|
|
|
118
|
-
message.nak(delay: options[:
|
|
118
|
+
message.nak(delay: options[:retry_in] * Config::NANO)
|
|
119
119
|
Logger.debug "concurrency limit reached for #{data[:class]}, re-queueing back #{data[:jid]}"
|
|
120
120
|
false
|
|
121
121
|
rescue NATS::Error => e
|
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
|
|
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); }
|
|
@@ -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
|
|
@@ -64,21 +64,36 @@
|
|
|
64
64
|
<% if @total_pages > 1 -%>
|
|
65
65
|
<div class="pagination">
|
|
66
66
|
<% if @page > 1 -%>
|
|
67
|
-
<a hx-get="<%= url_for('/jobs/
|
|
67
|
+
<a hx-get="<%= url_for('/jobs/enqueued', stream_name: @stream_name, page: @page - 1, limit: @limit) %>"
|
|
68
68
|
hx-target="#enqueued-poller"
|
|
69
69
|
hx-swap="outerHTML"
|
|
70
|
+
hx-push-url="true"
|
|
70
71
|
hx-indicator="#global-spinner"
|
|
71
72
|
class="btn">← Prev</a>
|
|
72
73
|
<% else -%>
|
|
73
74
|
<span class="btn btn-disabled">← Prev</span>
|
|
74
75
|
<% end -%>
|
|
75
76
|
|
|
76
|
-
|
|
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 -%>
|
|
77
91
|
|
|
78
92
|
<% if @page < @total_pages -%>
|
|
79
|
-
<a hx-get="<%= url_for('/jobs/
|
|
93
|
+
<a hx-get="<%= url_for('/jobs/enqueued', stream_name: @stream_name, page: @page + 1, limit: @limit) %>"
|
|
80
94
|
hx-target="#enqueued-poller"
|
|
81
95
|
hx-swap="outerHTML"
|
|
96
|
+
hx-push-url="true"
|
|
82
97
|
hx-indicator="#global-spinner"
|
|
83
98
|
class="btn">Next →</a>
|
|
84
99
|
<% else -%>
|
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
|