diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bd95f6..476f8ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index f6f0079..29f5a45 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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._ diff --git a/app/controllers/solid_stack_web/jobs_controller.rb b/app/controllers/solid_stack_web/jobs_controller.rb index b443190..b250ff1 100644 --- a/app/controllers/solid_stack_web/jobs_controller.rb +++ b/app/controllers/solid_stack_web/jobs_controller.rb @@ -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 diff --git a/app/views/solid_stack_web/jobs/index.html.erb b/app/views/solid_stack_web/jobs/index.html.erb index dcca7c0..90ffe46 100644 --- a/app/views/solid_stack_web/jobs/index.html.erb +++ b/app/views/solid_stack_web/jobs/index.html.erb @@ -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" %>Scheduled At<% end %> + <% if @status == "claimed" %>Wait Time<% end %> Actions @@ -132,6 +133,9 @@ <% if @status == "scheduled" %> <%= local_time(execution.scheduled_at) %> <% end %> + <% if @status == "claimed" %> + <%= format_duration(execution.created_at - execution.job.created_at) %> + <% end %> <% if @status == "scheduled" %> <%= button_to "Run Now", scheduled_job_path(execution), diff --git a/spec/requests/solid_stack_web/jobs_spec.rb b/spec/requests/solid_stack_web/jobs_spec.rb index ce620ec..c07854e 100644 --- a/spec/requests/solid_stack_web/jobs_spec.rb +++ b/spec/requests/solid_stack_web/jobs_spec.rb @@ -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")