Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Slow job webhook alert — fires when the number of currently-claimed jobs exceeding `slow_job_threshold` reaches `alert_slow_job_count_threshold`; respects the shared `alert_webhook_url` and `alert_webhook_cooldown` settings; payload includes `type: "slow_jobs"`, `count`, and `threshold`
- Stale process webhook alert — fires when the number of workers with a heartbeat older than 5 minutes meets `alert_stale_process_threshold`; reuses the shared webhook URL and cooldown; payload includes `type: "stale_processes"`, `count`, and `threshold`
- Job wait time column — shows how long a claimed job waited in the queue before being picked up (time from `enqueued_at` to claim time); displayed on the claimed tab only; also included as `wait_time_seconds` in CSV exports

## [1.2.0] - 2026-05-27

Expand Down
8 changes: 0 additions & 8 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,6 @@
---


## v1.3 — Alerting Depth

> _More signals, fewer blind spots._

- **Job wait time column** — show time from `enqueued_at` to `created_at` on claimed executions; a direct measure of queue SLA (how long jobs waited before a worker picked them up)

---

## v1.4 — Audit & Compliance

> _Requires an opt-in migration — kept separate from the no-migration-required releases above._
Expand Down
8 changes: 6 additions & 2 deletions app/controllers/solid_stack_web/jobs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,14 @@ def require_discardable

def jobs_csv
CSV.generate(headers: true) do |csv|
csv << %w[id class_name queue_name status priority enqueued_at]
headers = %w[id class_name queue_name status priority enqueued_at]
headers << "wait_time_seconds" if @status == "claimed"
csv << headers
filtered_scope.each do |execution|
job = execution.job
csv << [job.id, job.class_name, job.queue_name, @status, job.priority, job.created_at.iso8601]
row = [job.id, job.class_name, job.queue_name, @status, job.priority, job.created_at.iso8601]
row << (execution.created_at - job.created_at).to_i if @status == "claimed"
csv << row
end
end
end
Expand Down
4 changes: 4 additions & 0 deletions app/views/solid_stack_web/jobs/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
<%= sort_header_th("Priority", "priority", sort_url, current_sort: @sort, current_dir: @direction) %>
<%= sort_header_th("Enqueued At", "created_at", sort_url, current_sort: @sort, current_dir: @direction) %>
<% if @status == "scheduled" %><th scope="col">Scheduled At</th><% end %>
<% if @status == "claimed" %><th scope="col">Wait Time</th><% end %>
<th scope="col"><span class="sqw-sr-only">Actions</span></th>
</tr>
</thead>
Expand All @@ -132,6 +133,9 @@
<% if @status == "scheduled" %>
<td id="scheduled_at_<%= execution.id %>" class="sqw-muted"><%= local_time(execution.scheduled_at) %></td>
<% end %>
<% if @status == "claimed" %>
<td class="sqw-monospace"><%= format_duration(execution.created_at - execution.job.created_at) %></td>
<% end %>
<td class="sqw-actions">
<% if @status == "scheduled" %>
<%= button_to "Run Now", scheduled_job_path(execution),
Expand Down
45 changes: 45 additions & 0 deletions spec/requests/solid_stack_web/jobs_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,51 @@ def create_ready(class_name: "MyJob", queue_name: "default", priority: 0)
end
end

describe "wait time column (claimed tab)" do
def create_claimed(enqueued_ago:, claimed_ago:)
SolidQueue::Job.skip_callback(:create, :after, :prepare_for_execution)
job = SolidQueue::Job.create!(class_name: "WorkJob", queue_name: "default",
created_at: enqueued_ago.ago)
process = SolidQueue::Process.create!(kind: "Worker", name: "worker-wait-spec", pid: 88_888,
hostname: "test", last_heartbeat_at: Time.current)
execution = SolidQueue::ClaimedExecution.create!(job: job, process_id: process.id,
created_at: claimed_ago.ago)
SolidQueue::Job.set_callback(:create, :after, :prepare_for_execution)
execution
end

it "shows the Wait Time column header on the claimed tab" do
create_claimed(enqueued_ago: 5.minutes, claimed_ago: 2.minutes)
get "#{engine_root}/jobs", params: { status: "claimed" }
expect(response.body).to include("Wait Time")
end

it "does not show the Wait Time column header on other tabs" do
get "#{engine_root}/jobs", params: { status: "ready" }
expect(response.body).not_to include("Wait Time")
end

it "renders the formatted wait time for each claimed job" do
create_claimed(enqueued_ago: 5.minutes, claimed_ago: 2.minutes)
get "#{engine_root}/jobs", params: { status: "claimed" }
expect(response.body).to include("3m")
end

it "includes wait_time_seconds in the claimed CSV export" do
create_claimed(enqueued_ago: 5.minutes, claimed_ago: 2.minutes)
get "#{engine_root}/jobs.csv", params: { status: "claimed" }
rows = CSV.parse(response.body, headers: true)
expect(rows.first.headers).to include("wait_time_seconds")
expect(rows.first["wait_time_seconds"].to_i).to be_within(5).of(180)
end

it "does not include wait_time_seconds in the ready CSV export" do
get "#{engine_root}/jobs.csv", params: { status: "ready" }
headers = response.body.lines.first.chomp
expect(headers).not_to include("wait_time_seconds")
end
end

describe "combined filters" do
it "applies class and queue filters together" do
create_ready(class_name: "ReportJob", queue_name: "reports")
Expand Down