Skip to content

Commit 870dad5

Browse files
stevenchaninclaude
andauthored
Solid queue checks (#26)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent df388c9 commit 870dad5

13 files changed

Lines changed: 563 additions & 0 deletions

CHANGELOG.markdown

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
#### Unreleased
2+
* Add SolidQueue checks: `SolidQueueCheck` (liveness + job stats),
3+
`SolidQueueBackedUpCheck` (per-queue backlog), `SolidQueueFailedJobsCheck`
4+
(total failed jobs), `SolidQueueFailedJobsRateCheck` (rapid increase in
5+
failures within a rolling window), and `SolidQueueScheduledBackedUpCheck`
6+
(scheduled jobs overdue past a grace period)
7+
> stevenchanin: https://github.com/okcomputer-ruby/okcomputer/pull/26
8+
19
#### v1.19.2
210
* Rename organization from `emmahsax` to `okcomputer-ruby`
311
> emmahsax: https://github.com/okcomputer-ruby/okcomputer/pull/25

README.markdown

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,22 @@ OkComputer::Registry.register "resque_scheduler_down", OkComputer::ResqueSchedul
141141

142142
# If you're using SolidCache instead of Memcached, use this check instead of CacheCheck
143143
OkComputer::Registry.register "cache", OkComputer::CacheCheckSolidCache.new
144+
145+
# If you're using SolidQueue, these checks monitor its health and throughput.
146+
OkComputer::Registry.register "solid_queue", OkComputer::SolidQueueCheck.new
147+
148+
# Optionally, alert when a specific queue's backlog of ready jobs gets too high:
149+
OkComputer::Registry.register "solid_queue_backed_up", OkComputer::SolidQueueBackedUpCheck.new("default", 100)
150+
151+
# Optionally, alert when scheduled jobs are overdue — a sign the dispatcher has
152+
# stalled and is not promoting jobs to ready.
153+
OkComputer::Registry.register "solid_queue_scheduled_backed_up", OkComputer::SolidQueueScheduledBackedUpCheck.new(0, grace: 2.minutes)
154+
155+
# Optionally, alert when too many jobs have failed in total:
156+
OkComputer::Registry.register "solid_queue_failed_jobs", OkComputer::SolidQueueFailedJobsCheck.new(25)
157+
158+
# Optionally, alert on a rapid increase in failures (more than 10 failures in 300 sec)
159+
OkComputer::Registry.register "solid_queue_failed_jobs_rate", OkComputer::SolidQueueFailedJobsRateCheck.new(10, 300)
144160
```
145161

146162
### Registering Custom Checks
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
module OkComputer
2+
class SolidQueueBackedUpCheck < SizeThresholdCheck
3+
attr_accessor :queue
4+
attr_accessor :threshold
5+
6+
# Public: Initialize a check for a backed-up SolidQueue queue
7+
#
8+
# queue - The name of the SolidQueue queue to check
9+
# threshold - An Integer to compare the queue's number of ready jobs
10+
# against to consider it backed up
11+
def initialize(queue, threshold)
12+
self.queue = queue
13+
self.threshold = Integer(threshold)
14+
self.name = "SolidQueue queue '#{queue}'"
15+
end
16+
17+
# Public: The number of ready (pending) jobs in the check's queue
18+
def size
19+
SolidQueue::Queue.new(queue).size
20+
end
21+
end
22+
end
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
module OkComputer
2+
# Verifies that SolidQueue is up and processing jobs by confirming that at
3+
# least one worker process has a recent heartbeat, and reports a summary of
4+
# the current job counts.
5+
#
6+
# See https://github.com/rails/solid_queue
7+
class SolidQueueCheck < Check
8+
# Public: Check whether SolidQueue has live workers and a live dispatcher,
9+
# and report job stats
10+
def check
11+
if live_workers.zero?
12+
mark_failure
13+
mark_message "SolidQueue is DOWN. No workers are alive. (#{stats})"
14+
elsif live_dispatchers.zero?
15+
mark_failure
16+
mark_message "SolidQueue dispatcher is DOWN. Scheduled jobs will not run. (#{stats})"
17+
else
18+
mark_message "SolidQueue is up (#{live_workers} worker(s), #{live_dispatchers} dispatcher(s) alive). Job Counts: #{stats}"
19+
end
20+
rescue => e
21+
mark_failure
22+
mark_message "Error: '#{e}'"
23+
end
24+
25+
# Public: The number of worker processes whose heartbeat is within
26+
# SolidQueue's configured alive threshold (default: 5 minutes)
27+
def live_workers
28+
alive_processes.where(kind: "Worker").count
29+
end
30+
31+
# Public: The number of dispatcher processes whose heartbeat is recent enough
32+
# to be considered alive
33+
def live_dispatchers
34+
alive_processes.where(kind: "Dispatcher").count
35+
end
36+
37+
# Public: A summary of the current job counts across SolidQueue
38+
def stats
39+
"ready: #{ready}, scheduled: #{scheduled}, in progress: #{in_progress}, failed: #{failed}"
40+
end
41+
42+
private
43+
44+
# SolidQueue::Process records that have sent a heartbeat recently enough to
45+
# be considered alive. Mirrors SolidQueue's own Prunable logic.
46+
def alive_processes
47+
SolidQueue::Process.where("last_heartbeat_at > ?", SolidQueue.process_alive_threshold.ago)
48+
end
49+
50+
def ready
51+
SolidQueue::ReadyExecution.count
52+
end
53+
54+
def scheduled
55+
SolidQueue::ScheduledExecution.count
56+
end
57+
58+
def in_progress
59+
SolidQueue::ClaimedExecution.count
60+
end
61+
62+
def failed
63+
SolidQueue::FailedExecution.count
64+
end
65+
end
66+
end
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
module OkComputer
2+
class SolidQueueFailedJobsCheck < SizeThresholdCheck
3+
attr_accessor :threshold
4+
5+
# Public: Initialize a check for the total number of failed SolidQueue jobs
6+
#
7+
# threshold - An Integer to compare the failed job count against to
8+
# consider it over threshold
9+
def initialize(threshold)
10+
self.threshold = Integer(threshold)
11+
self.name = "SolidQueue Failed Jobs"
12+
end
13+
14+
# Public: The total number of failed jobs
15+
def size
16+
SolidQueue::FailedExecution.count
17+
end
18+
end
19+
end
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
module OkComputer
2+
# Detects rapid increases in failed SolidQueue jobs by counting failures
3+
# that occurred within a rolling time window, rather than the total
4+
# accumulated failures. This is stateless across requests: it relies on the
5+
# created_at timestamp of each failed execution.
6+
class SolidQueueFailedJobsRateCheck < SizeThresholdCheck
7+
attr_accessor :threshold
8+
attr_accessor :window
9+
10+
# Public: Initialize a check for the rate of failing SolidQueue jobs
11+
#
12+
# threshold - An Integer number of failures within the window to tolerate
13+
# before the check is considered failed
14+
# window - The size of the rolling window to count failures within. Accepts
15+
# either a number of seconds or an ActiveSupport::Duration (e.g.
16+
# 5.minutes). Defaults to 300 seconds (5 minutes).
17+
def initialize(threshold, window = 300)
18+
self.threshold = Integer(threshold)
19+
self.window = window
20+
self.name = "SolidQueue Failed Jobs Rate"
21+
end
22+
23+
# Public: The number of jobs that have failed within the window
24+
def size
25+
cutoff = window.respond_to?(:ago) ? window.ago : Time.now - window
26+
SolidQueue::FailedExecution.where("created_at > ?", cutoff).count
27+
end
28+
end
29+
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
module OkComputer
2+
# Detects a stalled SolidQueue dispatcher by counting scheduled jobs that are
3+
# overdue — i.e. their scheduled_at is more than `grace` in the past, so a
4+
# healthy dispatcher should already have promoted them to ready_executions.
5+
#
6+
# This is distinct from SolidQueueBackedUpCheck, which measures ready (already
7+
# promoted) depth. A dead/behind dispatcher leaves jobs stuck in scheduled and
8+
# invisible to that check; this check surfaces them.
9+
class SolidQueueScheduledBackedUpCheck < SizeThresholdCheck
10+
attr_accessor :threshold
11+
attr_accessor :grace
12+
13+
# Public: Initialize a check for overdue scheduled SolidQueue jobs
14+
#
15+
# threshold - An Integer; the number of overdue scheduled jobs to tolerate
16+
# before considering the dispatcher backed up.
17+
# grace - An ActiveSupport::Duration; how far past scheduled_at a job must be
18+
# before it counts as overdue. The dispatcher polls roughly every second
19+
# (config/queue.yml polling_interval), so sub-poll lateness is normal and a
20+
# grace window prevents flapping. Defaults to 1 minute.
21+
def initialize(threshold, grace: 1.minute)
22+
self.threshold = Integer(threshold)
23+
self.grace = grace
24+
self.name = "SolidQueue overdue scheduled jobs"
25+
end
26+
27+
# Public: Count of scheduled jobs overdue by more than `grace`. A healthy
28+
# dispatcher keeps this at 0.
29+
def size
30+
SolidQueue::ScheduledExecution.where("scheduled_at <= ?", grace.ago).count
31+
end
32+
end
33+
end

lib/okcomputer.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@
3434
require "ok_computer/built_in_checks/ruby_version_check"
3535
require "ok_computer/built_in_checks/sequel_check"
3636
require "ok_computer/built_in_checks/sidekiq_latency_check"
37+
require "ok_computer/built_in_checks/solid_queue_check"
38+
require "ok_computer/built_in_checks/solid_queue_backed_up_check"
39+
require "ok_computer/built_in_checks/solid_queue_scheduled_backed_up_check"
40+
require "ok_computer/built_in_checks/solid_queue_failed_jobs_check"
41+
require "ok_computer/built_in_checks/solid_queue_failed_jobs_rate_check"
3742
require "ok_computer/built_in_checks/solr_check"
3843

3944
OkComputer::Registry.register "default", OkComputer::DefaultCheck.new
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
require "rails_helper"
2+
3+
# Stubbing the constant out; will exist in apps which have SolidQueue loaded
4+
module SolidQueue
5+
class Queue; end
6+
end
7+
8+
module OkComputer
9+
describe SolidQueueBackedUpCheck do
10+
let(:queue) { "default" }
11+
let(:threshold) { 100 }
12+
13+
subject { SolidQueueBackedUpCheck.new(queue, threshold) }
14+
15+
it "is a Check" do
16+
expect(subject).to be_a Check
17+
end
18+
19+
context ".new(queue, threshold)" do
20+
it "accepts a queue name and a threshold to consider backed up" do
21+
expect(subject.queue).to eq(queue)
22+
expect(subject.threshold).to eq(threshold)
23+
end
24+
25+
it "coerces the threshold parameter into an integer" do
26+
expect(SolidQueueBackedUpCheck.new(queue, "123").threshold).to eq(123)
27+
end
28+
end
29+
30+
context "#check" do
31+
context "with the count less than the threshold" do
32+
before do
33+
allow(subject).to receive(:size) { threshold - 1 }
34+
end
35+
36+
it { is_expected.to be_successful_check }
37+
it { is_expected.to have_message "SolidQueue queue '#{queue}' at reasonable level (#{subject.size})" }
38+
end
39+
40+
context "with the count equal to the threshold" do
41+
before do
42+
allow(subject).to receive(:size) { threshold }
43+
end
44+
45+
it { is_expected.to be_successful_check }
46+
it { is_expected.to have_message "SolidQueue queue '#{queue}' at reasonable level (#{subject.size})" }
47+
end
48+
49+
context "with a count greater than the threshold" do
50+
before do
51+
allow(subject).to receive(:size) { threshold + 1 }
52+
end
53+
54+
it { is_expected.not_to be_successful_check }
55+
it { is_expected.to have_message "SolidQueue queue '#{queue}' is #{subject.size - subject.threshold} over threshold! (#{subject.size})" }
56+
end
57+
end
58+
59+
context "#size" do
60+
it "defers to SolidQueue::Queue for the ready job count" do
61+
solid_queue = double("SolidQueue::Queue", size: 42)
62+
expect(SolidQueue::Queue).to receive(:new).with(queue).and_return(solid_queue)
63+
expect(subject.size).to eq(42)
64+
end
65+
end
66+
end
67+
end
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
require "rails_helper"
2+
3+
# Stubbing the constants out; these will exist in apps which have SolidQueue loaded
4+
module SolidQueue
5+
def self.process_alive_threshold
6+
5.minutes
7+
end
8+
9+
class Process; end
10+
class ReadyExecution; end
11+
class ScheduledExecution; end
12+
class ClaimedExecution; end
13+
class FailedExecution; end
14+
end
15+
16+
module OkComputer
17+
describe SolidQueueCheck do
18+
it "is a Check" do
19+
expect(subject).to be_a Check
20+
end
21+
22+
context "#check" do
23+
context "when workers and a dispatcher are alive" do
24+
before do
25+
allow(subject).to receive(:live_workers).and_return(2)
26+
allow(subject).to receive(:live_dispatchers).and_return(1)
27+
allow(subject).to receive(:stats).and_return("ready: 0, scheduled: 0, in progress: 0, failed: 0")
28+
end
29+
30+
it { is_expected.to be_successful_check }
31+
it { is_expected.to have_message "SolidQueue is up (2 worker(s), 1 dispatcher(s) alive)." }
32+
it { is_expected.to have_message "Job Counts: ready: 0, scheduled: 0, in progress: 0, failed: 0" }
33+
end
34+
35+
context "when no workers are alive" do
36+
before do
37+
allow(subject).to receive(:live_workers).and_return(0)
38+
allow(subject).to receive(:stats).and_return("ready: 5, scheduled: 0, in progress: 0, failed: 0")
39+
end
40+
41+
it { is_expected.not_to be_successful_check }
42+
it { is_expected.to have_message "SolidQueue is DOWN. No workers are alive." }
43+
end
44+
45+
context "when workers are alive but the dispatcher is down" do
46+
before do
47+
allow(subject).to receive(:live_workers).and_return(2)
48+
allow(subject).to receive(:live_dispatchers).and_return(0)
49+
allow(subject).to receive(:stats).and_return("ready: 0, scheduled: 9, in progress: 0, failed: 0")
50+
end
51+
52+
it { is_expected.not_to be_successful_check }
53+
it { is_expected.to have_message "SolidQueue dispatcher is DOWN. Scheduled jobs will not run." }
54+
end
55+
56+
context "when an error occurs" do
57+
before do
58+
allow(subject).to receive(:live_workers).and_raise(StandardError, "boom")
59+
end
60+
61+
it { is_expected.not_to be_successful_check }
62+
it { is_expected.to have_message "Error: 'boom'" }
63+
end
64+
end
65+
66+
context "#live_workers" do
67+
it "counts worker processes with a recent heartbeat" do
68+
relation = double("relation")
69+
expect(SolidQueue::Process).to receive(:where).with("last_heartbeat_at > ?", anything).and_return(relation)
70+
expect(relation).to receive(:where).with(kind: "Worker").and_return(relation)
71+
expect(relation).to receive(:count).and_return(3)
72+
73+
expect(subject.live_workers).to eq(3)
74+
end
75+
end
76+
77+
context "#live_dispatchers" do
78+
it "counts dispatcher processes with a recent heartbeat" do
79+
relation = double("relation")
80+
expect(SolidQueue::Process).to receive(:where).with("last_heartbeat_at > ?", anything).and_return(relation)
81+
expect(relation).to receive(:where).with(kind: "Dispatcher").and_return(relation)
82+
expect(relation).to receive(:count).and_return(1)
83+
84+
expect(subject.live_dispatchers).to eq(1)
85+
end
86+
end
87+
88+
context "#stats" do
89+
it "summarizes the current job counts" do
90+
allow(SolidQueue::ReadyExecution).to receive(:count).and_return(4)
91+
allow(SolidQueue::ScheduledExecution).to receive(:count).and_return(1)
92+
allow(SolidQueue::ClaimedExecution).to receive(:count).and_return(2)
93+
allow(SolidQueue::FailedExecution).to receive(:count).and_return(0)
94+
95+
expect(subject.stats).to eq("ready: 4, scheduled: 1, in progress: 2, failed: 0")
96+
end
97+
end
98+
end
99+
end

0 commit comments

Comments
 (0)