job_board 0.1.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 +7 -0
- data/CHANGELOG.md +16 -0
- data/MIT-LICENSE +20 -0
- data/README.md +129 -0
- data/app/controllers/job_board/application_controller.rb +24 -0
- data/app/controllers/job_board/assets_controller.rb +19 -0
- data/app/controllers/job_board/jobs_controller.rb +88 -0
- data/app/controllers/job_board/processes_controller.rb +7 -0
- data/app/controllers/job_board/queues/pauses_controller.rb +15 -0
- data/app/controllers/job_board/queues_controller.rb +9 -0
- data/app/controllers/job_board/recurring_tasks_controller.rb +7 -0
- data/app/helpers/job_board/application_helper.rb +40 -0
- data/app/models/job_board/job_presenter.rb +37 -0
- data/app/models/job_board/job_row.rb +48 -0
- data/app/models/job_board/jobs_query.rb +86 -0
- data/app/models/job_board/latency_sla.rb +31 -0
- data/app/models/job_board/page.rb +35 -0
- data/app/models/job_board/process_tree.rb +49 -0
- data/app/views/job_board/jobs/_filters.html.erb +16 -0
- data/app/views/job_board/jobs/_job_row.html.erb +31 -0
- data/app/views/job_board/jobs/index.html.erb +63 -0
- data/app/views/job_board/jobs/show.html.erb +56 -0
- data/app/views/job_board/processes/_process.html.erb +28 -0
- data/app/views/job_board/processes/index.html.erb +32 -0
- data/app/views/job_board/queues/index.html.erb +50 -0
- data/app/views/job_board/recurring_tasks/index.html.erb +37 -0
- data/app/views/job_board/shared/_nav.html.erb +9 -0
- data/app/views/job_board/shared/_pagination.html.erb +5 -0
- data/app/views/layouts/job_board/application.html.erb +18 -0
- data/config/routes.rb +25 -0
- data/lib/job_board/assets/application.css +288 -0
- data/lib/job_board/assets/application.js +39 -0
- data/lib/job_board/configuration.rb +32 -0
- data/lib/job_board/engine.rb +5 -0
- data/lib/job_board/version.rb +3 -0
- data/lib/job_board.rb +17 -0
- metadata +108 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b9383d573ca40430673ec3e2ad7436edec021a856b72ebfcd45c3e557b8b4ac7
|
|
4
|
+
data.tar.gz: 6d0849f4f83e665364ea8769483adbbc53f9189ff8ddd50de6fb957d1adc76ae
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 544bcb5aa2cbddefa3d4ee47f708398e7fcda871addfbf95ac7184d5c5e48b332da2a34aec62ebb60158461c62c637d664df3ee0177336afa51b8d8cd93a1f39
|
|
7
|
+
data.tar.gz: 28f552a8ed1840b90b7984806dc2794674819ea442d9e1b6da439bc0fd4cbf465c6900544e39a3f9fe5b5ebc65dbf6771b4207f06f0f2807f25763f0ba2de0c4
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- Queues overview with per-queue ready count, latency, failed count, and pause/resume.
|
|
8
|
+
- Latency-SLA aware: `within_*` queue names (e.g. `within_5_minutes`) set their own
|
|
9
|
+
warning threshold and sort order; configurable per queue or globally.
|
|
10
|
+
- Jobs browsing by status (ready, scheduled, in progress, blocked, failed, finished)
|
|
11
|
+
with queue/class filters, keyset pagination, and a job detail page.
|
|
12
|
+
- Failed job management: retry/discard individually or in bulk.
|
|
13
|
+
- Workers page: supervisor tree, heartbeat freshness, stale detection, orphaned-job warning.
|
|
14
|
+
- Recurring tasks page with last/next run times.
|
|
15
|
+
- Zero-dependency UI served by the engine itself, with polling auto-refresh.
|
|
16
|
+
- Optional HTTP Basic auth via `JobBoard.configure`.
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2026 Mike
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Job Board
|
|
2
|
+
|
|
3
|
+
A mountable Rails engine that gives you a dashboard for [Solid Queue](https://github.com/rails/solid_queue):
|
|
4
|
+
queue latency at a glance, jobs by status, failed-job management, worker health, and recurring tasks.
|
|
5
|
+
|
|
6
|
+
## Features
|
|
7
|
+
|
|
8
|
+
- **Queues** — every queue with its ready-job count, **current latency** (how long the oldest
|
|
9
|
+
ready job has been waiting), and pause/resume controls.
|
|
10
|
+
- **Jobs** — browse by status (ready, scheduled, in progress, blocked, failed, finished) with
|
|
11
|
+
queue and class filters, keyset pagination that stays fast on huge tables, and a detail page
|
|
12
|
+
showing arguments, timestamps, and the full error with backtrace.
|
|
13
|
+
- **Failed job management** — retry or discard individual jobs, or all failed jobs matching the
|
|
14
|
+
current filters (batched, safe for large sets).
|
|
15
|
+
- **Workers** — the supervisor → worker/dispatcher/scheduler tree with heartbeat freshness,
|
|
16
|
+
stale-process badges, per-worker in-progress jobs, and a warning when claimed jobs have been
|
|
17
|
+
orphaned by a dead process.
|
|
18
|
+
- **Recurring tasks** — each task's cron schedule, last run, and next run.
|
|
19
|
+
- **Zero dependencies** — the engine serves its own CSS and ~40 lines of vanilla JS. No
|
|
20
|
+
importmap, sprockets, propshaft, or Node requirement. Pages auto-refresh every few seconds.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
Add the gem:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
gem "job_board"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Mount the engine:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# config/routes.rb
|
|
34
|
+
mount JobBoard::Engine => "/job_board"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
That's it — visit `/job_board`.
|
|
38
|
+
|
|
39
|
+
## Authentication
|
|
40
|
+
|
|
41
|
+
The dashboard ships with no forced authentication. Two options:
|
|
42
|
+
|
|
43
|
+
**Constraint-based mounting** (recommended when you have app auth, e.g. Devise):
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
authenticate :user, ->(user) { user.admin? } do
|
|
47
|
+
mount JobBoard::Engine => "/job_board"
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Built-in HTTP Basic auth:**
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
# config/initializers/job_board.rb
|
|
55
|
+
JobBoard.configure do |config|
|
|
56
|
+
config.http_basic_auth = {
|
|
57
|
+
name: Rails.application.credentials.dig(:job_board, :user),
|
|
58
|
+
password: Rails.application.credentials.dig(:job_board, :password)
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Configuration
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
JobBoard.configure do |config|
|
|
67
|
+
config.poll_interval = 5 # seconds between auto-refreshes; nil or 0 disables
|
|
68
|
+
config.per_page = 25 # rows per page on job lists
|
|
69
|
+
config.stale_process_threshold = nil # heartbeat age before a process is flagged stale;
|
|
70
|
+
# nil uses SolidQueue.process_alive_threshold (5 min)
|
|
71
|
+
config.http_basic_auth = nil # see Authentication above
|
|
72
|
+
|
|
73
|
+
# Latency (seconds) above which a queue is highlighted as breaching:
|
|
74
|
+
config.latency_warning_threshold = 60 # global default
|
|
75
|
+
config.latency_warning_thresholds = { "critical" => 10 } # per-queue overrides
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Latency-SLA queue names
|
|
80
|
+
|
|
81
|
+
If you name queues after their latency target — the `within_*` convention from
|
|
82
|
+
[Gusto's Sidekiq scaling write-up](https://engineering.gusto.com/scaling-sidekiq-at-gusto-3f9e3279e63) —
|
|
83
|
+
Job Board parses the threshold straight from the name, no configuration needed:
|
|
84
|
+
|
|
85
|
+
| Queue | Highlighted red when latency exceeds |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `within_30_seconds` | 30 seconds |
|
|
88
|
+
| `within_5_minutes` | 5 minutes |
|
|
89
|
+
| `within_1_hour` | 1 hour |
|
|
90
|
+
| `within_24_hours` | 24 hours |
|
|
91
|
+
|
|
92
|
+
Explicit `latency_warning_thresholds` entries still win over the naming convention.
|
|
93
|
+
|
|
94
|
+
The queues page also sorts by SLA: queues with a detectable latency target come first
|
|
95
|
+
(strictest at the top), and everything else follows alphabetically.
|
|
96
|
+
|
|
97
|
+
## Notes
|
|
98
|
+
|
|
99
|
+
- **Separate queue database**: works automatically. Job Board reads and writes exclusively
|
|
100
|
+
through the `SolidQueue::*` models, which connect to whatever database
|
|
101
|
+
`config.solid_queue.connects_to` points at.
|
|
102
|
+
- **Latency** is computed the same way Solid Queue itself defines it: seconds since the oldest
|
|
103
|
+
ready execution in the queue was enqueued (0 for an empty queue). Scheduled and failed jobs
|
|
104
|
+
don't count against latency.
|
|
105
|
+
- **`preserve_finished_jobs = false`** hosts will see an explanatory empty state on the
|
|
106
|
+
Finished tab, since finished jobs are deleted immediately.
|
|
107
|
+
- **Discarding is permanent** — Solid Queue deletes the job row entirely; there is no trash.
|
|
108
|
+
In-progress jobs can't be discarded.
|
|
109
|
+
- Queue names containing `/` can't be paused through the UI (route limitation).
|
|
110
|
+
|
|
111
|
+
## Development
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
bundle install
|
|
115
|
+
bundle exec rake test
|
|
116
|
+
|
|
117
|
+
# Run the demo app with seeded data:
|
|
118
|
+
cd test/dummy
|
|
119
|
+
bin/rails db:prepare db:seed
|
|
120
|
+
bin/rails server
|
|
121
|
+
# open http://localhost:3000/job_board
|
|
122
|
+
|
|
123
|
+
# Optionally start real workers against the seeded jobs:
|
|
124
|
+
bin/rails solid_queue:start
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module JobBoard
|
|
2
|
+
class ApplicationController < ActionController::Base
|
|
3
|
+
protect_from_forgery with: :exception
|
|
4
|
+
|
|
5
|
+
before_action :authenticate
|
|
6
|
+
|
|
7
|
+
layout "job_board/application"
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
def authenticate
|
|
11
|
+
credentials = JobBoard.config.http_basic_auth
|
|
12
|
+
return unless credentials
|
|
13
|
+
|
|
14
|
+
authenticate_or_request_with_http_basic("JobBoard") do |name, password|
|
|
15
|
+
ActiveSupport::SecurityUtils.secure_compare(name, credentials[:name].to_s) &
|
|
16
|
+
ActiveSupport::SecurityUtils.secure_compare(password, credentials[:password].to_s)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def stale_threshold
|
|
21
|
+
JobBoard.config.stale_process_threshold || SolidQueue.process_alive_threshold
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module JobBoard
|
|
2
|
+
class AssetsController < ApplicationController
|
|
3
|
+
skip_forgery_protection
|
|
4
|
+
|
|
5
|
+
ASSETS = {
|
|
6
|
+
"application.css" => "text/css",
|
|
7
|
+
"application.js" => "text/javascript"
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
10
|
+
def show
|
|
11
|
+
name = params[:name]
|
|
12
|
+
content_type = ASSETS[name] or return head(:not_found)
|
|
13
|
+
|
|
14
|
+
expires_in 1.year, public: true
|
|
15
|
+
send_file JobBoard::Engine.root.join("lib/job_board/assets", name),
|
|
16
|
+
type: content_type, disposition: :inline
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
module JobBoard
|
|
2
|
+
class JobsController < ApplicationController
|
|
3
|
+
before_action :set_query, only: [:index, :retry_all, :discard_all]
|
|
4
|
+
|
|
5
|
+
def index
|
|
6
|
+
@page = @query.page(before: params[:before], limit: JobBoard.config.per_page)
|
|
7
|
+
@counts = @query.counts
|
|
8
|
+
@queues = LatencySla.sort(SolidQueue::Queue.all)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def show
|
|
12
|
+
@job = JobPresenter.new(find_job)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def retry
|
|
16
|
+
job = find_job
|
|
17
|
+
if job.failed_execution.present?
|
|
18
|
+
job.retry
|
|
19
|
+
redirect_back_or_to jobs_path(status: "failed"), notice: "Job ##{job.id} queued for retry."
|
|
20
|
+
else
|
|
21
|
+
redirect_back_or_to jobs_path, alert: "Job ##{job.id} is not failed, so it can't be retried."
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def discard
|
|
26
|
+
job = find_job
|
|
27
|
+
if job.status == :claimed
|
|
28
|
+
redirect_back_or_to jobs_path, alert: "Job ##{job.id} is in progress and can't be discarded."
|
|
29
|
+
elsif job.finished?
|
|
30
|
+
redirect_back_or_to jobs_path, alert: "Job ##{job.id} is already finished."
|
|
31
|
+
else
|
|
32
|
+
job.discard
|
|
33
|
+
redirect_to jobs_path(status: params[:status] || "failed"), notice: "Job ##{job.id} discarded."
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def retry_all
|
|
38
|
+
count = 0
|
|
39
|
+
failed_jobs_in_batches do |batch|
|
|
40
|
+
SolidQueue::FailedExecution.retry_all(batch)
|
|
41
|
+
count += batch.size
|
|
42
|
+
end
|
|
43
|
+
redirect_to failed_jobs_path, notice: "#{count} #{"job".pluralize(count)} queued for retry."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def discard_all
|
|
47
|
+
count = 0
|
|
48
|
+
failed_jobs_in_batches do |batch|
|
|
49
|
+
SolidQueue::FailedExecution.discard_all_from_jobs(batch)
|
|
50
|
+
count += batch.size
|
|
51
|
+
end
|
|
52
|
+
redirect_to failed_jobs_path, notice: "#{count} #{"job".pluralize(count)} discarded."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
def set_query
|
|
57
|
+
status = params[:status].presence || "ready"
|
|
58
|
+
head :not_found and return unless JobsQuery::STATUSES.include?(status)
|
|
59
|
+
|
|
60
|
+
@query = JobsQuery.new(
|
|
61
|
+
status: status,
|
|
62
|
+
queue_name: params[:queue_name].presence,
|
|
63
|
+
class_name: params[:class_name].presence
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def find_job
|
|
68
|
+
SolidQueue::Job
|
|
69
|
+
.includes(:ready_execution, :scheduled_execution, :claimed_execution,
|
|
70
|
+
:blocked_execution, :failed_execution)
|
|
71
|
+
.find(params[:id])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def failed_jobs_in_batches(&block)
|
|
75
|
+
query = JobsQuery.new(
|
|
76
|
+
status: "failed",
|
|
77
|
+
queue_name: params[:queue_name].presence,
|
|
78
|
+
class_name: params[:class_name].presence
|
|
79
|
+
)
|
|
80
|
+
query.failed_jobs_relation.find_in_batches(batch_size: 500, &block)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def failed_jobs_path
|
|
84
|
+
jobs_path(status: "failed", queue_name: params[:queue_name].presence,
|
|
85
|
+
class_name: params[:class_name].presence)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module JobBoard
|
|
2
|
+
module Queues
|
|
3
|
+
class PausesController < ApplicationController
|
|
4
|
+
def create
|
|
5
|
+
SolidQueue::Queue.find_by_name(params[:queue_name]).pause
|
|
6
|
+
redirect_to queues_path, notice: "Queue \"#{params[:queue_name]}\" paused."
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def destroy
|
|
10
|
+
SolidQueue::Queue.find_by_name(params[:queue_name]).resume
|
|
11
|
+
redirect_to queues_path, notice: "Queue \"#{params[:queue_name]}\" resumed."
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module JobBoard
|
|
2
|
+
module ApplicationHelper
|
|
3
|
+
def status_badge(status)
|
|
4
|
+
tag.span(status.to_s.humanize, class: "badge badge--#{status}")
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def time_ago(time)
|
|
8
|
+
return tag.span("–", class: "muted") if time.blank?
|
|
9
|
+
|
|
10
|
+
time = Time.zone.parse(time) if time.is_a?(String)
|
|
11
|
+
tag.time("#{duration((Time.current - time).abs)} #{time.future? ? "from now" : "ago"}",
|
|
12
|
+
title: time.iso8601, datetime: time.iso8601)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def duration(seconds)
|
|
16
|
+
seconds = seconds.to_i
|
|
17
|
+
return "0s" if seconds <= 0
|
|
18
|
+
|
|
19
|
+
parts = { "d" => 86400, "h" => 3600, "m" => 60, "s" => 1 }.filter_map do |unit, size|
|
|
20
|
+
value, seconds = seconds.divmod(size)
|
|
21
|
+
"#{value}#{unit}" if value.positive?
|
|
22
|
+
end
|
|
23
|
+
parts.first(2).join(" ")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Latency (seconds) above which a queue counts as breaching. Resolution order:
|
|
27
|
+
# explicit per-queue config, a threshold parsed from a within_* style name,
|
|
28
|
+
# then the global default.
|
|
29
|
+
def latency_threshold_for(queue_name)
|
|
30
|
+
LatencySla.threshold_for(queue_name)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def format_json(value)
|
|
34
|
+
value = JSON.parse(value) if value.is_a?(String)
|
|
35
|
+
JSON.pretty_generate(value)
|
|
36
|
+
rescue JSON::ParserError, JSON::GeneratorError
|
|
37
|
+
value.to_s
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module JobBoard
|
|
2
|
+
# Detail-page wrapper around a SolidQueue::Job loaded with all five
|
|
3
|
+
# has_one execution associations preloaded.
|
|
4
|
+
class JobPresenter
|
|
5
|
+
attr_reader :job
|
|
6
|
+
|
|
7
|
+
delegate :id, :queue_name, :class_name, :priority, :active_job_id,
|
|
8
|
+
:concurrency_key, :created_at, :finished_at, :finished?,
|
|
9
|
+
:failed_execution, :status, to: :job
|
|
10
|
+
|
|
11
|
+
def initialize(job)
|
|
12
|
+
@job = job
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The serialized ActiveJob payload (Job#arguments is JSON-coded by SolidQueue).
|
|
16
|
+
def payload
|
|
17
|
+
@payload ||= job.arguments || {}
|
|
18
|
+
rescue StandardError
|
|
19
|
+
@payload = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def arguments = payload["arguments"]
|
|
23
|
+
def executions_count = payload["executions"]
|
|
24
|
+
def exception_executions = payload["exception_executions"]
|
|
25
|
+
def enqueued_at = payload["enqueued_at"]
|
|
26
|
+
|
|
27
|
+
def scheduled_at
|
|
28
|
+
job.scheduled_execution&.scheduled_at || job.scheduled_at
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def error = failed_execution
|
|
32
|
+
|
|
33
|
+
def process
|
|
34
|
+
job.claimed_execution&.process
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module JobBoard
|
|
2
|
+
# Uniform row facade so the jobs table doesn't care whether a row came from
|
|
3
|
+
# an execution record or a finished job.
|
|
4
|
+
class JobRow
|
|
5
|
+
attr_reader :job, :status, :execution
|
|
6
|
+
|
|
7
|
+
def self.from_execution(execution, status:)
|
|
8
|
+
new(job: execution.job, status: status, execution: execution)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.from_job(job, status: :finished)
|
|
12
|
+
new(job: job, status: status)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(job:, status:, execution: nil)
|
|
16
|
+
@job = job
|
|
17
|
+
@status = status.to_sym
|
|
18
|
+
@execution = execution
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def id = job.id
|
|
22
|
+
def queue_name = job.queue_name
|
|
23
|
+
def class_name = job.class_name
|
|
24
|
+
def priority = job.priority
|
|
25
|
+
def enqueued_at = job.created_at
|
|
26
|
+
def finished_at = job.finished_at
|
|
27
|
+
|
|
28
|
+
def scheduled_at
|
|
29
|
+
status == :scheduled ? execution.scheduled_at : job.scheduled_at
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def process
|
|
33
|
+
execution.process if status == :claimed && execution
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def concurrency_key
|
|
37
|
+
execution.concurrency_key if status == :blocked && execution
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def error_class
|
|
41
|
+
execution.exception_class if status == :failed && execution
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def error_message
|
|
45
|
+
execution.message if status == :failed && execution
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module JobBoard
|
|
2
|
+
# Status-scoped job finder. Each status is driven by its execution table so a
|
|
3
|
+
# row's status is known without calling Job#status (which needs 5 preloads).
|
|
4
|
+
class JobsQuery
|
|
5
|
+
STATUSES = %w[ready scheduled in_progress blocked failed finished].freeze
|
|
6
|
+
|
|
7
|
+
ROW_STATUS = {
|
|
8
|
+
"ready" => :ready, "scheduled" => :scheduled, "in_progress" => :claimed,
|
|
9
|
+
"blocked" => :blocked, "failed" => :failed, "finished" => :finished
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :status, :queue_name, :class_name
|
|
13
|
+
|
|
14
|
+
def initialize(status:, queue_name: nil, class_name: nil)
|
|
15
|
+
@status = status.to_s
|
|
16
|
+
@queue_name = queue_name
|
|
17
|
+
@class_name = class_name
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def page(before: nil, limit: 25)
|
|
21
|
+
row_status = ROW_STATUS.fetch(status)
|
|
22
|
+
builder =
|
|
23
|
+
if status == "finished"
|
|
24
|
+
->(job) { JobRow.from_job(job) }
|
|
25
|
+
else
|
|
26
|
+
->(execution) { JobRow.from_execution(execution, status: row_status) }
|
|
27
|
+
end
|
|
28
|
+
Page.new(relation.order(id: :desc), before: before, limit: limit, &builder)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# One COUNT per status, with the current filters applied — for tab badges.
|
|
32
|
+
def counts
|
|
33
|
+
STATUSES.index_with do |s|
|
|
34
|
+
self.class.new(status: s, queue_name: queue_name, class_name: class_name).count
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def count
|
|
39
|
+
relation.count(:all)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Job relation backing bulk retry/discard. Only meaningful for the failed set,
|
|
43
|
+
# so it is always scoped to jobs with a failed execution.
|
|
44
|
+
def failed_jobs_relation
|
|
45
|
+
scope = SolidQueue::Job.joins(:failed_execution)
|
|
46
|
+
scope = scope.where(queue_name: queue_name) if queue_name
|
|
47
|
+
scope = scope.where(class_name: class_name) if class_name
|
|
48
|
+
scope
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
def relation
|
|
53
|
+
case status
|
|
54
|
+
when "ready"
|
|
55
|
+
filtered(SolidQueue::ReadyExecution.includes(:job), own_queue_column: true)
|
|
56
|
+
when "scheduled"
|
|
57
|
+
filtered(SolidQueue::ScheduledExecution.includes(:job), own_queue_column: true)
|
|
58
|
+
when "in_progress"
|
|
59
|
+
filtered(SolidQueue::ClaimedExecution.includes(:process, :job), own_queue_column: false)
|
|
60
|
+
when "blocked"
|
|
61
|
+
filtered(SolidQueue::BlockedExecution.includes(:job), own_queue_column: true)
|
|
62
|
+
when "failed"
|
|
63
|
+
filtered(SolidQueue::FailedExecution.includes(:job), own_queue_column: false)
|
|
64
|
+
when "finished"
|
|
65
|
+
filtered_jobs(SolidQueue::Job.finished)
|
|
66
|
+
else
|
|
67
|
+
raise ArgumentError, "unknown status #{status.inspect}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def filtered(scope, own_queue_column:)
|
|
72
|
+
if queue_name
|
|
73
|
+
scope = own_queue_column ? scope.where(queue_name: queue_name)
|
|
74
|
+
: scope.joins(:job).where(solid_queue_jobs: { queue_name: queue_name })
|
|
75
|
+
end
|
|
76
|
+
scope = scope.joins(:job).where(solid_queue_jobs: { class_name: class_name }) if class_name
|
|
77
|
+
scope
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def filtered_jobs(scope)
|
|
81
|
+
scope = scope.where(queue_name: queue_name) if queue_name
|
|
82
|
+
scope = scope.where(class_name: class_name) if class_name
|
|
83
|
+
scope
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module JobBoard
|
|
2
|
+
# Detects a queue's latency SLA from explicit configuration or from the
|
|
3
|
+
# within_* naming convention (e.g. "within_5_minutes" => 300).
|
|
4
|
+
module LatencySla
|
|
5
|
+
UNITS = { "second" => 1, "minute" => 60, "hour" => 3600, "day" => 86400 }.freeze
|
|
6
|
+
|
|
7
|
+
# Seconds, or nil when the queue has no detectable SLA.
|
|
8
|
+
def self.detect(queue_name)
|
|
9
|
+
JobBoard.config.latency_warning_thresholds[queue_name.to_s] || from_name(queue_name)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# The latency-warning threshold: the detected SLA, else the global default.
|
|
13
|
+
def self.threshold_for(queue_name)
|
|
14
|
+
detect(queue_name) || JobBoard.config.latency_warning_threshold
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Queues with a detectable SLA first, strictest first; the rest alphabetical.
|
|
18
|
+
def self.sort(queues)
|
|
19
|
+
queues.sort_by do |queue|
|
|
20
|
+
sla = detect(queue.name)
|
|
21
|
+
[sla ? 0 : 1, sla || 0, queue.name]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.from_name(queue_name)
|
|
26
|
+
match = /\Awithin_(\d+)_(second|minute|hour|day)s?\z/i.match(queue_name.to_s)
|
|
27
|
+
match && match[1].to_i * UNITS.fetch(match[2].downcase)
|
|
28
|
+
end
|
|
29
|
+
private_class_method :from_name
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module JobBoard
|
|
2
|
+
# Keyset pagination over a relation ordered by id DESC.
|
|
3
|
+
# Fetches limit + 1 records to detect whether an older page exists.
|
|
4
|
+
class Page
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
attr_reader :records, :rows
|
|
8
|
+
|
|
9
|
+
def initialize(relation, before: nil, limit: 25, &row_builder)
|
|
10
|
+
relation = relation.where(id: ...before.to_i) if before.present?
|
|
11
|
+
fetched = relation.limit(limit + 1).to_a
|
|
12
|
+
@more = fetched.size > limit
|
|
13
|
+
@records = fetched.first(limit)
|
|
14
|
+
@rows = row_builder ? @records.map(&row_builder) : @records
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def more?
|
|
18
|
+
@more
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Keyset cursor for the next (older) page — the id of the raw record,
|
|
22
|
+
# which is an execution id for execution-backed lists and a job id for finished.
|
|
23
|
+
def next_before
|
|
24
|
+
records.last&.id
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def each(&block)
|
|
28
|
+
rows.each(&block)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def empty?
|
|
32
|
+
records.empty?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module JobBoard
|
|
2
|
+
# Supervisor -> supervisee tree of SolidQueue processes with staleness flags,
|
|
3
|
+
# claimed-job counts, and the in-progress jobs each worker holds.
|
|
4
|
+
class ProcessTree
|
|
5
|
+
Node = Data.define(:process, :children, :claimed_count, :stale)
|
|
6
|
+
|
|
7
|
+
attr_reader :roots, :orphaned_claimed_count
|
|
8
|
+
|
|
9
|
+
def self.build(stale_threshold:)
|
|
10
|
+
new(stale_threshold: stale_threshold)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(stale_threshold:)
|
|
14
|
+
processes = SolidQueue::Process.order(:id).to_a
|
|
15
|
+
ids = processes.map(&:id).to_set
|
|
16
|
+
counts = SolidQueue::ClaimedExecution.group(:process_id).count
|
|
17
|
+
@claimed_by_process =
|
|
18
|
+
SolidQueue::ClaimedExecution.where(process_id: processes.map(&:id))
|
|
19
|
+
.includes(:job).group_by(&:process_id)
|
|
20
|
+
|
|
21
|
+
cutoff = stale_threshold.ago
|
|
22
|
+
children = processes.group_by(&:supervisor_id)
|
|
23
|
+
build_node = nil
|
|
24
|
+
build_node = lambda do |process|
|
|
25
|
+
Node.new(
|
|
26
|
+
process: process,
|
|
27
|
+
children: (children[process.id] || []).map(&build_node),
|
|
28
|
+
claimed_count: counts[process.id] || 0,
|
|
29
|
+
stale: process.last_heartbeat_at < cutoff
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Roots: no supervisor, or a supervisor that no longer exists.
|
|
34
|
+
root_processes = processes.select { |p| p.supervisor_id.nil? || !ids.include?(p.supervisor_id) }
|
|
35
|
+
supervisors, others = root_processes.partition { |p| p.kind.to_s.start_with?("Supervisor") }
|
|
36
|
+
@roots = (supervisors + others).map(&build_node)
|
|
37
|
+
|
|
38
|
+
@orphaned_claimed_count = SolidQueue::ClaimedExecution.orphaned.count
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def claimed_executions_for(process)
|
|
42
|
+
@claimed_by_process[process.id] || []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def empty?
|
|
46
|
+
roots.empty?
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|