cosmonats 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE.txt +21 -169
- data/README.md +102 -18
- data/lib/cosmo/api/counter.rb +14 -11
- data/lib/cosmo/api/cron/entry.rb +23 -3
- data/lib/cosmo/api/cron.rb +22 -26
- data/lib/cosmo/api/job.rb +30 -0
- data/lib/cosmo/api/kv.rb +31 -11
- data/lib/cosmo/api/registry.rb +68 -0
- data/lib/cosmo/api/stats/busy.rb +63 -0
- data/lib/cosmo/api/stats/processes.rb +37 -0
- data/lib/cosmo/api/stats/totals.rb +35 -0
- data/lib/cosmo/api/stats.rb +5 -4
- data/lib/cosmo/api/stream.rb +1 -1
- data/lib/cosmo/api.rb +3 -2
- data/lib/cosmo/batch.rb +9 -2
- data/lib/cosmo/cli.rb +27 -7
- data/lib/cosmo/client.rb +69 -6
- data/lib/cosmo/config.rb +6 -2
- data/lib/cosmo/engine.rb +103 -4
- data/lib/cosmo/heartbeat.rb +129 -0
- data/lib/cosmo/http_server/handler.rb +44 -0
- data/lib/cosmo/http_server/health.rb +39 -0
- data/lib/cosmo/http_server/ping.rb +23 -0
- data/lib/cosmo/http_server.rb +62 -0
- data/lib/cosmo/job/failure.rb +52 -0
- data/lib/cosmo/job/processor.rb +87 -25
- data/lib/cosmo/job/stream_filter.rb +54 -0
- data/lib/cosmo/job.rb +2 -0
- data/lib/cosmo/logger.rb +21 -3
- data/lib/cosmo/processor.rb +46 -16
- data/lib/cosmo/sentry/job_processor_middleware.rb +1 -2
- data/lib/cosmo/stream/message.rb +2 -1
- data/lib/cosmo/stream/processor.rb +5 -0
- data/lib/cosmo/utils/json.rb +1 -1
- data/lib/cosmo/utils/overrides.rb +199 -8
- data/lib/cosmo/utils/thread_pool.rb +5 -0
- data/lib/cosmo/version.rb +1 -1
- data/lib/cosmo/web/assets/app.css +21 -0
- data/lib/cosmo/web/controllers/jobs.rb +28 -4
- data/lib/cosmo/web/helpers/application.rb +10 -1
- data/lib/cosmo/web/views/batches/index.erb +1 -1
- data/lib/cosmo/web/views/jobs/_busy.erb +70 -4
- data/lib/cosmo/web/views/jobs/_dead.erb +29 -16
- data/lib/cosmo/web/views/jobs/_enqueued.erb +10 -0
- data/lib/cosmo/web/views/jobs/_processes.erb +131 -0
- data/lib/cosmo/web/views/jobs/_scheduled.erb +1 -1
- data/lib/cosmo/web/views/jobs/busy.erb +1 -1
- data/lib/cosmo/web/views/jobs/dead.erb +2 -2
- data/lib/cosmo/web/views/jobs/enqueued.erb +1 -1
- data/lib/cosmo/web/views/jobs/index.erb +7 -1
- data/lib/cosmo/web/views/jobs/scheduled.erb +1 -1
- data/lib/cosmo/web/views/streams/index.erb +1 -1
- data/lib/cosmo/web.rb +2 -0
- data/lib/cosmo.rb +13 -0
- data/sig/cosmo/api/counter.rbs +2 -4
- data/sig/cosmo/api/cron/entry.rbs +2 -0
- data/sig/cosmo/api/cron.rbs +4 -2
- data/sig/cosmo/api/job.rbs +12 -0
- data/sig/cosmo/api/kv.rbs +7 -1
- data/sig/cosmo/api/registry.rbs +30 -0
- data/sig/cosmo/api/stats/busy.rbs +31 -0
- data/sig/cosmo/api/stats/processes.rbs +16 -0
- data/sig/cosmo/api/stats/totals.rbs +17 -0
- data/sig/cosmo/cli.rbs +4 -0
- data/sig/cosmo/client.rbs +17 -2
- data/sig/cosmo/engine.rbs +29 -0
- data/sig/cosmo/heartbeat.rbs +36 -0
- data/sig/cosmo/http_server/handler.rbs +17 -0
- data/sig/cosmo/http_server/health.rbs +21 -0
- data/sig/cosmo/http_server/ping.rbs +14 -0
- data/sig/cosmo/http_server.rbs +22 -0
- data/sig/cosmo/job/failure.rbs +28 -0
- data/sig/cosmo/job/processor.rbs +17 -1
- data/sig/cosmo/job/stream_filter.rbs +25 -0
- data/sig/cosmo/logger.rbs +10 -0
- data/sig/cosmo/processor.rbs +12 -4
- data/sig/cosmo/stream/message.rbs +3 -1
- data/sig/cosmo/stream/processor.rbs +2 -0
- data/sig/cosmo/utils/thread_pool.rbs +3 -0
- data/sig/cosmo.rbs +4 -0
- metadata +33 -20
- data/lib/cosmo/api/busy.rb +0 -66
- data/sig/cosmo/api/busy.rbs +0 -35
|
@@ -1,15 +1,206 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
Cosmo::Utils::Warnings.silence do
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
unless NATS::JetStream::API::StreamConfig.members.include?(:allow_msg_schedules)
|
|
5
|
+
members = NATS::JetStream::API::StreamConfig.members + %i[allow_msg_counter allow_msg_schedules]
|
|
6
|
+
NATS::JetStream::API::StreamConfig = Struct.new(*members, keyword_init: true) do
|
|
7
|
+
def initialize(opts = {})
|
|
8
|
+
rem = opts.keys - members
|
|
9
|
+
opts.delete_if { |k| rem.include?(k) }
|
|
10
|
+
super
|
|
11
|
+
end
|
|
10
12
|
end
|
|
11
13
|
end
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
unless NATS::JetStream::PubAck.members.include?(:val)
|
|
16
|
+
members = NATS::JetStream::PubAck.members + [:val]
|
|
17
|
+
NATS::JetStream::PubAck = Struct.new(*members, keyword_init: true)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Upstream bug in nats-pure 2.5.0 and 2.6.0 (https://github.com/nats-io/nats-pure.rb):
|
|
22
|
+
# NATS::JetStream::PullSubscription#fetch only assigns its `start_time` local in the
|
|
23
|
+
# `batch > 1` branch, but the final "did we time out" check outside the case statement
|
|
24
|
+
# reads it unconditionally. For `batch == 1` (what Cosmo::Processor always uses),
|
|
25
|
+
# `start_time` is nil there -- normally masked because the `batch == 1` branch has its
|
|
26
|
+
# own timeout check (using a properly-set `t`) that raises first.
|
|
27
|
+
#
|
|
28
|
+
# It only surfaces once two threads call #fetch concurrently on the *same* subscription:
|
|
29
|
+
# @pending_queue/wait_for_msgs_cond are shared per-subscription state, and #dispatch's
|
|
30
|
+
# `wait_for_msgs_cond.signal` wakes exactly one arbitrary waiter -- not necessarily the
|
|
31
|
+
# one whose own request produced that wakeup. A thread woken by someone else's signal
|
|
32
|
+
# finds nothing left in the (already-drained) queue, so it skips its own inner timeout
|
|
33
|
+
# check and falls into the buggy tail check, raising `TypeError: nil can't be coerced
|
|
34
|
+
# into Float` instead of the intended timeout/empty result. Reproduced deterministically
|
|
35
|
+
# with N threads fetching in a loop against the same PullSubscription.
|
|
36
|
+
#
|
|
37
|
+
# Second, separate upstream bug in the same method: the post-wait timeout check below
|
|
38
|
+
# (`raise ... if MonotonicTime.since(t) > timeout`) fires unconditionally, unlike every
|
|
39
|
+
# other timeout check in this method (all guarded with `msgs.empty? &&`). If the unsynchronized
|
|
40
|
+
# pop just above it succeeded in grabbing a real, already-delivered message -- which can happen
|
|
41
|
+
# even after `timeout` has technically elapsed, since #dispatch's signal and this thread actually
|
|
42
|
+
# resuming are two different moments -- that message is already gone from @pending_queue with
|
|
43
|
+
# nowhere else to go, yet gets discarded here and a plain NATS::Timeout raised instead. Callers
|
|
44
|
+
# (including Cosmo::Processor#fetch) treat NATS::Timeout as "no messages", so the message is lost
|
|
45
|
+
# silently: never processed, never acked, and not redelivered until the consumer's ack_wait expires.
|
|
46
|
+
# Reproduces reliably with several threads fetching (batch: 1) concurrently against the same
|
|
47
|
+
# subscription, e.g. Cosmo::Job::Processor's thread pool.
|
|
48
|
+
#
|
|
49
|
+
# Vendored copy of nats-pure's PullSubscription#fetch, kept as close to
|
|
50
|
+
# upstream as possible (two one-line fixes, see comments above), so it's easy to diff against the
|
|
51
|
+
# next nats-pure release and drop once fixed there. Fixed on nats-pure main by the fetch rewrite
|
|
52
|
+
# (#181), recognized by its private #take_pending, in which case this copy is not loaded.
|
|
53
|
+
return if NATS::JetStream.const_get(:PullSubscription).private_method_defined?(:take_pending)
|
|
54
|
+
|
|
55
|
+
# rubocop:disable all
|
|
56
|
+
module NATS
|
|
57
|
+
class JetStream
|
|
58
|
+
module PullSubscription
|
|
59
|
+
def fetch(batch = 1, params = {})
|
|
60
|
+
raise ::NATS::JetStream::Error.new("nats: invalid batch size") if batch < 1
|
|
61
|
+
|
|
62
|
+
t = MonotonicTime.now
|
|
63
|
+
start_time = t # fixes the upstream bug -- see comment above
|
|
64
|
+
timeout = params[:timeout] ||= 5
|
|
65
|
+
expires = (timeout * 1_000_000_000) - 100_000
|
|
66
|
+
next_req = { batch: batch }
|
|
67
|
+
|
|
68
|
+
msgs = []
|
|
69
|
+
case
|
|
70
|
+
when batch == 1
|
|
71
|
+
synchronize do
|
|
72
|
+
unless @pending_queue.empty?
|
|
73
|
+
msg = @pending_queue.pop
|
|
74
|
+
@pending_size -= msg.data.size
|
|
75
|
+
if JS.is_status_msg(msg)
|
|
76
|
+
case msg.header["Status"]
|
|
77
|
+
when JS::Status::NoMsgs then nil
|
|
78
|
+
when JS::Status::RequestTimeout then nil # Skip
|
|
79
|
+
else raise JS.from_msg(msg)
|
|
80
|
+
end
|
|
81
|
+
else
|
|
82
|
+
msgs << msg
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
next_req[:expires] = expires
|
|
88
|
+
if msgs.empty?
|
|
89
|
+
@nc.publish(@jsi.nms, JS.next_req_to_json(next_req), @subject)
|
|
90
|
+
synchronize { wait_for_msgs_cond.wait(timeout) }
|
|
91
|
+
|
|
92
|
+
unless @pending_queue.empty?
|
|
93
|
+
msg = @pending_queue.pop
|
|
94
|
+
@pending_size -= msg.data.size
|
|
95
|
+
msgs << msg
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
raise ::NATS::Timeout.new("nats: fetch timeout") if msgs.empty? && (MonotonicTime.since(t) > timeout)
|
|
99
|
+
|
|
100
|
+
if JS.is_status_msg(msgs.first)
|
|
101
|
+
msg = msgs.first
|
|
102
|
+
case msg.header[JS::Header::Status]
|
|
103
|
+
when JS::Status::RequestTimeout then raise NATS::Timeout.new("nats: fetch request timeout")
|
|
104
|
+
else raise JS.from_msg(msgs.first)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
when batch > 1
|
|
109
|
+
synchronize do
|
|
110
|
+
if batch <= @pending_queue.size
|
|
111
|
+
batch.times do
|
|
112
|
+
msg = @pending_queue.pop
|
|
113
|
+
@pending_size -= msg.data.size
|
|
114
|
+
if JS.is_status_msg(msg)
|
|
115
|
+
case msg.header[JS::Header::Status]
|
|
116
|
+
when JS::Status::NoMsgs, JS::Status::RequestTimeout then next
|
|
117
|
+
else raise JS.from_msg(msg)
|
|
118
|
+
end
|
|
119
|
+
else
|
|
120
|
+
msgs << msg
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
return msgs
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
next_req[:no_wait] = true
|
|
128
|
+
@nc.publish(@jsi.nms, JS.next_req_to_json(next_req), @subject)
|
|
129
|
+
|
|
130
|
+
start_time = MonotonicTime.now
|
|
131
|
+
msg = nil
|
|
132
|
+
|
|
133
|
+
synchronize do
|
|
134
|
+
wait_for_msgs_cond.wait(timeout)
|
|
135
|
+
unless @pending_queue.empty?
|
|
136
|
+
msg = @pending_queue.pop
|
|
137
|
+
@pending_size -= msg.data.size
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if !msg.nil? && JS.is_status_msg(msg)
|
|
142
|
+
case msg.header[JS::Header::Status]
|
|
143
|
+
when JS::Status::NoMsgs
|
|
144
|
+
next_req[:expires] = expires
|
|
145
|
+
next_req.delete(:no_wait)
|
|
146
|
+
@nc.publish(@jsi.nms, JS.next_req_to_json(next_req), @subject)
|
|
147
|
+
when JS::Status::RequestTimeout
|
|
148
|
+
raise NATS::Timeout.new("nats: fetch request timeout")
|
|
149
|
+
else
|
|
150
|
+
raise JS.from_msg(msg)
|
|
151
|
+
end
|
|
152
|
+
else
|
|
153
|
+
msgs << msg unless msg.nil?
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
duration = MonotonicTime.since(start_time)
|
|
157
|
+
raise NATS::Timeout.new("nats: fetch timeout") if msgs.empty? && (duration > timeout)
|
|
158
|
+
|
|
159
|
+
needed = batch - msgs.count
|
|
160
|
+
while (needed > 0) && (MonotonicTime.since(start_time) < timeout)
|
|
161
|
+
duration = MonotonicTime.since(start_time)
|
|
162
|
+
|
|
163
|
+
synchronize do
|
|
164
|
+
if @pending_queue.empty?
|
|
165
|
+
deadline = timeout - duration
|
|
166
|
+
wait_for_msgs_cond.wait(deadline) if deadline > 0
|
|
167
|
+
|
|
168
|
+
duration = MonotonicTime.since(start_time)
|
|
169
|
+
if msgs.empty? && @pending_queue.empty? && (duration > timeout)
|
|
170
|
+
raise NATS::Timeout.new("nats: fetch timeout")
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
unless @pending_queue.empty?
|
|
175
|
+
msg = @pending_queue.pop
|
|
176
|
+
@pending_size -= msg.data.size
|
|
177
|
+
|
|
178
|
+
if JS.is_status_msg(msg)
|
|
179
|
+
case msg.header[JS::Header::Status]
|
|
180
|
+
when JS::Status::NoMsgs, JS::Status::RequestTimeout
|
|
181
|
+
duration = MonotonicTime.since(start_time)
|
|
182
|
+
if duration > timeout
|
|
183
|
+
raise NATS::Timeout.new("nats: fetch timeout") if msgs.empty?
|
|
184
|
+
|
|
185
|
+
return msgs
|
|
186
|
+
end
|
|
187
|
+
else
|
|
188
|
+
raise JS.from_msg(msg)
|
|
189
|
+
end
|
|
190
|
+
else
|
|
191
|
+
msgs << msg
|
|
192
|
+
needed -= 1
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
raise ::NATS::Timeout.new("nats: fetch timeout") if msgs.empty? && (MonotonicTime.since(start_time) > timeout)
|
|
200
|
+
|
|
201
|
+
msgs
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
15
205
|
end
|
|
206
|
+
# rubocop:enable all
|
|
@@ -16,11 +16,16 @@ module Cosmo
|
|
|
16
16
|
|
|
17
17
|
def initialize(concurrency)
|
|
18
18
|
@mutex = Thread::Mutex.new
|
|
19
|
+
@concurrency = concurrency
|
|
19
20
|
@available = concurrency
|
|
20
21
|
@cond = ConditionVariable.new
|
|
21
22
|
@pool = Concurrent::FixedThreadPool.new(concurrency)
|
|
22
23
|
end
|
|
23
24
|
|
|
25
|
+
def wait_idle
|
|
26
|
+
@mutex.synchronize { @cond.wait(@mutex) until @available == @concurrency }
|
|
27
|
+
end
|
|
28
|
+
|
|
24
29
|
def post
|
|
25
30
|
@mutex.synchronize do
|
|
26
31
|
@cond.wait(@mutex) while @available <= 0
|
data/lib/cosmo/version.rb
CHANGED
|
@@ -163,6 +163,14 @@ button:hover, .btn:hover {
|
|
|
163
163
|
);
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
.btn-sm {
|
|
167
|
+
box-shadow: none;
|
|
168
|
+
font-size: var(--font-size-small);
|
|
169
|
+
font-weight: 600;
|
|
170
|
+
line-height: var(--space-2x);
|
|
171
|
+
padding: var(--space-1-2) var(--space);
|
|
172
|
+
}
|
|
173
|
+
|
|
166
174
|
/* ── Badges ────────────────────────────────────────────────────────────── */
|
|
167
175
|
.badge {
|
|
168
176
|
border-radius: var(--space-1-2);
|
|
@@ -343,6 +351,12 @@ section .pending {
|
|
|
343
351
|
align-items: center;
|
|
344
352
|
}
|
|
345
353
|
|
|
354
|
+
section .pending .poll-status {
|
|
355
|
+
align-items: center;
|
|
356
|
+
display: inline-flex;
|
|
357
|
+
gap: var(--space);
|
|
358
|
+
}
|
|
359
|
+
|
|
346
360
|
/* ── Stat cards ────────────────────────────────────────────────────────── */
|
|
347
361
|
.cards-container {
|
|
348
362
|
display: grid;
|
|
@@ -479,6 +493,13 @@ time {
|
|
|
479
493
|
font-size: var(--font-size-small);
|
|
480
494
|
word-wrap: break-word;
|
|
481
495
|
}
|
|
496
|
+
.error-message details code {
|
|
497
|
+
max-width: 600px;
|
|
498
|
+
max-height: 320px;
|
|
499
|
+
overflow: auto;
|
|
500
|
+
white-space: pre;
|
|
501
|
+
font-size: var(--font-size-small);
|
|
502
|
+
}
|
|
482
503
|
|
|
483
504
|
/* ── Links ─────────────────────────────────────────────────────────────── */
|
|
484
505
|
.stream-link { color: var(--color-primary); font-weight: 600; }
|
|
@@ -54,6 +54,13 @@ module Cosmo
|
|
|
54
54
|
ok
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
def delete_enqueued
|
|
58
|
+
seq = path.split("/").last.to_i
|
|
59
|
+
stream_name, = streams
|
|
60
|
+
API::Stream.new(stream_name).delete(seq)
|
|
61
|
+
_enqueued
|
|
62
|
+
end
|
|
63
|
+
|
|
57
64
|
def _scheduled
|
|
58
65
|
stream = API::Stream.new("scheduled")
|
|
59
66
|
jobs = stream.messages(page: params["page"], limit: params["limit"])
|
|
@@ -67,9 +74,19 @@ module Cosmo
|
|
|
67
74
|
end
|
|
68
75
|
|
|
69
76
|
def _busy
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
77
|
+
busy = API::Stats::Busy.instance
|
|
78
|
+
total = busy.size
|
|
79
|
+
page, limit, total_pages = paginate(total, API::Stats::Busy::LIMIT)
|
|
80
|
+
polling = params["poll"].to_s != "0"
|
|
81
|
+
ok render("jobs/_busy", { jobs: busy.list(page:, limit:), total:, page:, limit:, total_pages:, polling: })
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def _processes
|
|
85
|
+
processes = API::Stats::Processes.instance
|
|
86
|
+
total = processes.size
|
|
87
|
+
page, limit, total_pages = paginate(total, API::Stats::Processes::LIMIT)
|
|
88
|
+
polling = params["poll"].to_s != "0"
|
|
89
|
+
ok render("jobs/_processes", { processes: processes.list(page:, limit:), total:, page:, limit:, total_pages:, polling: })
|
|
73
90
|
end
|
|
74
91
|
|
|
75
92
|
def _enqueued # rubocop:disable Metrics/AbcSize
|
|
@@ -80,8 +97,9 @@ module Cosmo
|
|
|
80
97
|
unless stream_name.to_s.empty?
|
|
81
98
|
stream = API::Stream.new(stream_name)
|
|
82
99
|
total = stream.total
|
|
83
|
-
jobs = stream.messages(page:, limit:)
|
|
84
100
|
total_pages = (total.to_f / limit).ceil
|
|
101
|
+
page = page.clamp(1, [total_pages, 1].max)
|
|
102
|
+
jobs = stream.messages(page:, limit:)
|
|
85
103
|
end
|
|
86
104
|
|
|
87
105
|
ok render("jobs/_enqueued", { jobs:, total:, stream_name:, stream_names:, page:, limit:, total_pages: })
|
|
@@ -93,6 +111,12 @@ module Cosmo
|
|
|
93
111
|
|
|
94
112
|
private
|
|
95
113
|
|
|
114
|
+
def paginate(total, default_limit)
|
|
115
|
+
limit = (params["limit"] || default_limit).to_i
|
|
116
|
+
total_pages = (total.to_f / limit).ceil
|
|
117
|
+
[params["page"].to_i.clamp(1, [total_pages, 1].max), limit, total_pages]
|
|
118
|
+
end
|
|
119
|
+
|
|
96
120
|
def streams
|
|
97
121
|
stream_names = API::Stream.jobs.map(&:name)
|
|
98
122
|
stream_name = params.fetch("stream_name", stream_names.first)
|
|
@@ -8,6 +8,13 @@ module Cosmo
|
|
|
8
8
|
module Application
|
|
9
9
|
include Renderer
|
|
10
10
|
|
|
11
|
+
# Seconds between htmx auto-refreshes. Override with COSMO_WEB_POLL_INTERVAL.
|
|
12
|
+
POLL_INTERVAL = 5
|
|
13
|
+
|
|
14
|
+
def poll_interval
|
|
15
|
+
@poll_interval ||= ENV.fetch("COSMO_WEB_POLL_INTERVAL", POLL_INTERVAL).to_i
|
|
16
|
+
end
|
|
17
|
+
|
|
11
18
|
def render(template, locals = nil)
|
|
12
19
|
defaults = { request: @request }
|
|
13
20
|
locals = Hash(locals).merge(defaults)
|
|
@@ -42,8 +49,10 @@ module Cosmo
|
|
|
42
49
|
"#{elapsed}s"
|
|
43
50
|
elsif elapsed < 3600
|
|
44
51
|
"#{elapsed / 60}m #{elapsed % 60}s"
|
|
45
|
-
|
|
52
|
+
elsif elapsed < 86_400
|
|
46
53
|
"#{elapsed / 3600}h #{(elapsed % 3600) / 60}m"
|
|
54
|
+
else
|
|
55
|
+
"#{elapsed / 86_400}d #{(elapsed % 86_400) / 3600}h"
|
|
47
56
|
end
|
|
48
57
|
end
|
|
49
58
|
|
|
@@ -1,10 +1,29 @@
|
|
|
1
|
+
<% nav = @polling ? { limit: @limit } : { limit: @limit, poll: 0 } -%>
|
|
1
2
|
<div id="busy-poller"
|
|
2
|
-
|
|
3
|
-
hx-
|
|
4
|
-
hx-
|
|
3
|
+
<% if @polling -%>
|
|
4
|
+
hx-get="<%= url_for('/jobs/_busy', page: @page, limit: @limit) %>"
|
|
5
|
+
hx-trigger="every <%= poll_interval %>s"
|
|
6
|
+
hx-swap="outerHTML"
|
|
7
|
+
<% end -%>
|
|
8
|
+
>
|
|
5
9
|
<div class="pending">
|
|
6
10
|
<span></span>
|
|
7
|
-
<span class="
|
|
11
|
+
<span class="poll-status">
|
|
12
|
+
<span class="text-muted"><%= @total %> job(s) running</span>
|
|
13
|
+
<% if @polling -%>
|
|
14
|
+
<a class="btn btn-sm"
|
|
15
|
+
hx-get="<%= url_for('/jobs/_busy', nav.merge(page: @page, poll: 0)) %>"
|
|
16
|
+
hx-target="#busy-poller"
|
|
17
|
+
hx-swap="outerHTML"
|
|
18
|
+
hx-indicator="#global-spinner">⏹ Stop Polling</a>
|
|
19
|
+
<% else -%>
|
|
20
|
+
<a class="btn btn-sm btn-primary"
|
|
21
|
+
hx-get="<%= url_for('/jobs/_busy', page: @page, limit: @limit) %>"
|
|
22
|
+
hx-target="#busy-poller"
|
|
23
|
+
hx-swap="outerHTML"
|
|
24
|
+
hx-indicator="#global-spinner">▶ Live Poll</a>
|
|
25
|
+
<% end -%>
|
|
26
|
+
</span>
|
|
8
27
|
</div>
|
|
9
28
|
|
|
10
29
|
<% if @jobs.empty? -%>
|
|
@@ -18,6 +37,7 @@
|
|
|
18
37
|
<th>JID[Args]</th>
|
|
19
38
|
<th>Stream</th>
|
|
20
39
|
<th>Worker</th>
|
|
40
|
+
<th>Attempt</th>
|
|
21
41
|
<th>Running for</th>
|
|
22
42
|
</tr>
|
|
23
43
|
</thead>
|
|
@@ -43,6 +63,14 @@
|
|
|
43
63
|
<code><%= h(job[:stream].to_s) %></code>
|
|
44
64
|
</td>
|
|
45
65
|
<td><code style="font-size: var(--font-size-small);"><%= h(job[:worker].to_s) %></code></td>
|
|
66
|
+
<td>
|
|
67
|
+
<% delivery = job[:delivery].to_i -%>
|
|
68
|
+
<% if delivery > 1 -%>
|
|
69
|
+
<span class="badge <%= delivery >= 5 ? 'badge-danger' : 'badge-warning' %>"><%= delivery %></span>
|
|
70
|
+
<% else -%>
|
|
71
|
+
<span class="text-muted"><%= delivery.zero? ? '—' : delivery %></span>
|
|
72
|
+
<% end -%>
|
|
73
|
+
</td>
|
|
46
74
|
<td>
|
|
47
75
|
<%= elapsed(job[:started_at]) %>
|
|
48
76
|
</td>
|
|
@@ -52,4 +80,42 @@
|
|
|
52
80
|
</table>
|
|
53
81
|
</div>
|
|
54
82
|
<% end -%>
|
|
83
|
+
|
|
84
|
+
<% if @total_pages.to_i > 1 -%>
|
|
85
|
+
<div class="pagination">
|
|
86
|
+
<% if @page > 1 -%>
|
|
87
|
+
<a hx-get="<%= url_for('/jobs/_busy', nav.merge(page: @page - 1)) %>"
|
|
88
|
+
hx-target="#busy-poller"
|
|
89
|
+
hx-swap="outerHTML"
|
|
90
|
+
hx-indicator="#global-spinner"
|
|
91
|
+
class="btn">← Prev</a>
|
|
92
|
+
<% else -%>
|
|
93
|
+
<span class="btn btn-disabled">← Prev</span>
|
|
94
|
+
<% end -%>
|
|
95
|
+
|
|
96
|
+
<% pages(@page, @total_pages).each do |p| -%>
|
|
97
|
+
<% if p == :gap -%>
|
|
98
|
+
<span class="pagination-gap">…</span>
|
|
99
|
+
<% elsif p == @page -%>
|
|
100
|
+
<span class="btn btn-primary btn-disabled"><%= p %></span>
|
|
101
|
+
<% else -%>
|
|
102
|
+
<a hx-get="<%= url_for('/jobs/_busy', nav.merge(page: p)) %>"
|
|
103
|
+
hx-target="#busy-poller"
|
|
104
|
+
hx-swap="outerHTML"
|
|
105
|
+
hx-indicator="#global-spinner"
|
|
106
|
+
class="btn"><%= p %></a>
|
|
107
|
+
<% end -%>
|
|
108
|
+
<% end -%>
|
|
109
|
+
|
|
110
|
+
<% if @page < @total_pages -%>
|
|
111
|
+
<a hx-get="<%= url_for('/jobs/_busy', nav.merge(page: @page + 1)) %>"
|
|
112
|
+
hx-target="#busy-poller"
|
|
113
|
+
hx-swap="outerHTML"
|
|
114
|
+
hx-indicator="#global-spinner"
|
|
115
|
+
class="btn">Next →</a>
|
|
116
|
+
<% else -%>
|
|
117
|
+
<span class="btn btn-disabled">Next →</span>
|
|
118
|
+
<% end -%>
|
|
119
|
+
</div>
|
|
120
|
+
<% end -%>
|
|
55
121
|
</div>
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
<div id="dead-
|
|
2
|
-
hx-get="<%= url_for('/jobs/_dead') %>"
|
|
3
|
-
hx-trigger="every 5s"
|
|
4
|
-
hx-swap="outerHTML">
|
|
1
|
+
<div id="dead-jobs">
|
|
5
2
|
<% if @jobs.empty? -%>
|
|
6
3
|
<div class="alert alert-success">No dead jobs found</div>
|
|
7
4
|
<% else -%>
|
|
@@ -35,31 +32,47 @@
|
|
|
35
32
|
</details>
|
|
36
33
|
</div>
|
|
37
34
|
</td>
|
|
38
|
-
<td>
|
|
39
|
-
|
|
35
|
+
<td style="white-space: nowrap;">
|
|
36
|
+
<% if job.x_stream -%><code><%= h(job.x_stream) %></code><% else -%><span class="text-muted">—</span><% end -%>
|
|
40
37
|
</td>
|
|
41
|
-
<td style="
|
|
42
|
-
<% if job.
|
|
38
|
+
<td style="min-width: 420px;">
|
|
39
|
+
<% if job.error? -%>
|
|
43
40
|
<div class="error-message">
|
|
44
|
-
<strong><%= h(job.
|
|
45
|
-
<p><%= h(job.
|
|
41
|
+
<strong><%= h(job.error_class.to_s.empty? ? "Error" : job.error_class) %></strong>
|
|
42
|
+
<p><%= h(job.error_message) %></p>
|
|
43
|
+
<% if job.error_backtrace -%>
|
|
44
|
+
<details>
|
|
45
|
+
<summary>Backtrace</summary>
|
|
46
|
+
<code><%= h(job.error_backtrace_lines.join("\n")) %></code>
|
|
47
|
+
</details>
|
|
48
|
+
<% end -%>
|
|
49
|
+
</div>
|
|
50
|
+
<% else -%>
|
|
51
|
+
<div class="error-message">
|
|
52
|
+
<strong>Unknown</strong>
|
|
53
|
+
<p>No error recorded for this job</p>
|
|
46
54
|
</div>
|
|
47
55
|
<% end -%>
|
|
48
56
|
</td>
|
|
49
57
|
<td style="white-space: nowrap;">
|
|
50
|
-
|
|
58
|
+
<% if (failed_at = job.stored_at) -%>
|
|
59
|
+
<time datetime="<%= h(job.timestamp) %>" title="<%= h(job.timestamp) %>"><%= h(format_timestamp(failed_at)) %></time>
|
|
60
|
+
<div class="text-muted" style="font-size: var(--font-size-small);"><%= elapsed(failed_at) %> ago</div>
|
|
61
|
+
<% else -%>
|
|
62
|
+
<span class="text-muted">—</span>
|
|
63
|
+
<% end -%>
|
|
51
64
|
</td>
|
|
52
|
-
<td>
|
|
65
|
+
<td style="white-space: nowrap;">
|
|
53
66
|
<a hx-patch="<%= url_for("/jobs/retry/#{job.seq}") %>"
|
|
54
67
|
hx-target="#dead-row-<%= job.seq %>"
|
|
55
68
|
hx-swap="innerHTML"
|
|
56
|
-
|
|
57
|
-
|
|
69
|
+
title="Retry"
|
|
70
|
+
class="btn btn-primary btn-sm">↻</a>
|
|
58
71
|
<a hx-delete="<%= url_for("/jobs/delete/#{job.seq}") %>"
|
|
59
72
|
hx-target="#dead-row-<%= job.seq %>"
|
|
60
73
|
hx-swap="innerHTML"
|
|
61
|
-
|
|
62
|
-
|
|
74
|
+
title="Delete"
|
|
75
|
+
class="btn btn-danger btn-sm">✕</a>
|
|
63
76
|
</td>
|
|
64
77
|
</tr>
|
|
65
78
|
<% end -%>
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
<th>JID[Args]</th>
|
|
31
31
|
<th>Stream</th>
|
|
32
32
|
<th>Seq</th>
|
|
33
|
+
<th>Actions</th>
|
|
33
34
|
</tr>
|
|
34
35
|
</thead>
|
|
35
36
|
<tbody>
|
|
@@ -56,6 +57,15 @@
|
|
|
56
57
|
<td>
|
|
57
58
|
<code><%= job.seq %></code>
|
|
58
59
|
</td>
|
|
60
|
+
<td>
|
|
61
|
+
<a hx-delete="<%= url_for("/jobs/enqueued/#{job.seq}", { stream_name: @stream_name, page: @page, limit: @limit }) %>"
|
|
62
|
+
hx-target="#enqueued-poller"
|
|
63
|
+
hx-swap="outerHTML"
|
|
64
|
+
hx-indicator="#global-spinner"
|
|
65
|
+
hx-confirm="Remove message #<%= job.seq %> from <%= h(@stream_name) %>?"
|
|
66
|
+
title="Remove message"
|
|
67
|
+
class="btn btn-danger btn-sm">✕</a>
|
|
68
|
+
</td>
|
|
59
69
|
</tr>
|
|
60
70
|
<% end -%>
|
|
61
71
|
</tbody>
|