queue_pulse 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 +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +105 -0
- data/lib/queue_pulse/alert.rb +44 -0
- data/lib/queue_pulse/check_job.rb +17 -0
- data/lib/queue_pulse/checks/base.rb +40 -0
- data/lib/queue_pulse/checks/failures.rb +67 -0
- data/lib/queue_pulse/checks/queue_depth.rb +35 -0
- data/lib/queue_pulse/checks/queue_latency.rb +40 -0
- data/lib/queue_pulse/checks/stuck_jobs.rb +42 -0
- data/lib/queue_pulse/checks/worker_liveness.rb +35 -0
- data/lib/queue_pulse/configuration.rb +87 -0
- data/lib/queue_pulse/memory_store.rb +48 -0
- data/lib/queue_pulse/monitor.rb +73 -0
- data/lib/queue_pulse/notifiers/base.rb +73 -0
- data/lib/queue_pulse/notifiers/email.rb +50 -0
- data/lib/queue_pulse/notifiers/slack.rb +38 -0
- data/lib/queue_pulse/notifiers/test.rb +23 -0
- data/lib/queue_pulse/notifiers/webhook.rb +29 -0
- data/lib/queue_pulse/poller.rb +52 -0
- data/lib/queue_pulse/railtie.rb +12 -0
- data/lib/queue_pulse/source/solid_queue.rb +97 -0
- data/lib/queue_pulse/source.rb +127 -0
- data/lib/queue_pulse/state.rb +44 -0
- data/lib/queue_pulse/version.rb +5 -0
- data/lib/queue_pulse.rb +74 -0
- data/lib/tasks/queue_pulse.rake +9 -0
- metadata +168 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module QueuePulse
|
|
6
|
+
# Runs one monitoring pass: evaluate enabled checks, collect alerts, deliver
|
|
7
|
+
# to every notifier. Heavily isolated — a failing check or notifier is logged
|
|
8
|
+
# and never propagates to the host app (Requirements US-6.1, US-6.5, US-7.4/5).
|
|
9
|
+
class Monitor
|
|
10
|
+
CHECK_CLASSES = {
|
|
11
|
+
failures: Checks::Failures,
|
|
12
|
+
queue_latency: Checks::QueueLatency,
|
|
13
|
+
queue_depth: Checks::QueueDepth,
|
|
14
|
+
stuck_jobs: Checks::StuckJobs,
|
|
15
|
+
worker_liveness: Checks::WorkerLiveness
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(config:, source: nil)
|
|
19
|
+
@config = config
|
|
20
|
+
@source = source || Source::SolidQueue.new
|
|
21
|
+
@state = State.new(config.cache)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Array<QueuePulse::Alert>] delivered alerts
|
|
25
|
+
def run
|
|
26
|
+
unless @source.available?
|
|
27
|
+
logger.warn("[QueuePulse] Solid Queue tables not found; skipping checks")
|
|
28
|
+
return []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if @config.notifiers.empty?
|
|
32
|
+
logger.warn("[QueuePulse] no notifiers configured; skipping checks")
|
|
33
|
+
return []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
alerts = Timeout.timeout(@config.check_timeout) { collect_alerts }
|
|
37
|
+
deliver(alerts)
|
|
38
|
+
alerts
|
|
39
|
+
rescue Timeout::Error
|
|
40
|
+
logger.error("[QueuePulse] check pass exceeded #{@config.check_timeout}s and was aborted")
|
|
41
|
+
[]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def collect_alerts
|
|
47
|
+
Configuration::ALL_CHECKS.each_with_object([]) do |name, alerts|
|
|
48
|
+
next unless @config.check_enabled?(name)
|
|
49
|
+
|
|
50
|
+
klass = CHECK_CLASSES.fetch(name)
|
|
51
|
+
begin
|
|
52
|
+
alerts.concat(klass.new(@config, @source, @state).call)
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
logger.error("[QueuePulse] check #{name} failed: #{e.class}: #{e.message}")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def deliver(alerts)
|
|
60
|
+
return if alerts.empty?
|
|
61
|
+
|
|
62
|
+
@config.notifiers.each do |notifier|
|
|
63
|
+
notifier.deliver(alerts)
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
logger.error("[QueuePulse] notifier #{notifier.class} failed: #{e.class}: #{e.message}")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def logger
|
|
70
|
+
@config.logger
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module QueuePulse
|
|
8
|
+
module Notifiers
|
|
9
|
+
# Shared behaviour for notifiers. Subclasses implement #deliver(alerts).
|
|
10
|
+
# HTTP helpers retry up to 2 times on timeout/non-2xx and apply timeouts so
|
|
11
|
+
# a slow endpoint can't hang a check (Requirements US-6.6).
|
|
12
|
+
class Base
|
|
13
|
+
MAX_RETRIES = 2
|
|
14
|
+
OPEN_TIMEOUT = 5
|
|
15
|
+
READ_TIMEOUT = 5
|
|
16
|
+
|
|
17
|
+
# @param alerts [Array<QueuePulse::Alert>]
|
|
18
|
+
def deliver(_alerts)
|
|
19
|
+
raise NotImplementedError
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
protected
|
|
23
|
+
|
|
24
|
+
def logger
|
|
25
|
+
QueuePulse.logger
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def post_json(url, payload, headers: {})
|
|
29
|
+
body = JSON.generate(payload)
|
|
30
|
+
post(url, body: body, headers: { "Content-Type" => "application/json" }.merge(headers))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def post(url, body:, headers: {})
|
|
34
|
+
uri = URI.parse(url)
|
|
35
|
+
attempt = 0
|
|
36
|
+
begin
|
|
37
|
+
attempt += 1
|
|
38
|
+
response = http_post(uri, body, headers)
|
|
39
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
40
|
+
raise QueuePulse::Error, "HTTP #{response.code} from #{uri.host}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
response
|
|
44
|
+
rescue QueuePulse::Error, Timeout::Error, SystemCallError, SocketError, Net::HTTPBadResponse, OpenSSL::SSL::SSLError => e
|
|
45
|
+
if attempt <= MAX_RETRIES
|
|
46
|
+
sleep(retry_backoff(attempt))
|
|
47
|
+
retry
|
|
48
|
+
end
|
|
49
|
+
raise QueuePulse::Error, "delivery to #{uri.host} failed after #{attempt} attempts: #{e.message}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Overridable so tests can avoid real sleeping.
|
|
54
|
+
def retry_backoff(attempt)
|
|
55
|
+
0.5 * attempt
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def http_post(uri, body, headers)
|
|
61
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
62
|
+
http.use_ssl = uri.scheme == "https"
|
|
63
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
64
|
+
http.read_timeout = READ_TIMEOUT
|
|
65
|
+
|
|
66
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
67
|
+
headers.each { |k, v| request[k] = v }
|
|
68
|
+
request.body = body
|
|
69
|
+
http.request(request)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueuePulse
|
|
4
|
+
module Notifiers
|
|
5
|
+
# Sends a plain-text digest email via ActionMailer. Body building is
|
|
6
|
+
# separated for testing. Requires ActionMailer to be configured in the host
|
|
7
|
+
# app (Requirements US-6.4).
|
|
8
|
+
class Email < Base
|
|
9
|
+
def initialize(to:, from: "queue_pulse@localhost", subject_prefix: "[QueuePulse]")
|
|
10
|
+
super()
|
|
11
|
+
@to = to
|
|
12
|
+
@from = from
|
|
13
|
+
@subject_prefix = subject_prefix
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def deliver(alerts)
|
|
17
|
+
return if alerts.empty?
|
|
18
|
+
|
|
19
|
+
unless defined?(ActionMailer::Base)
|
|
20
|
+
logger.warn("[QueuePulse] Email notifier requires ActionMailer; skipping #{alerts.size} alert(s)")
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
mail = ActionMailer::Base.mail(
|
|
25
|
+
to: @to,
|
|
26
|
+
from: @from,
|
|
27
|
+
subject: subject(alerts),
|
|
28
|
+
body: body(alerts)
|
|
29
|
+
)
|
|
30
|
+
mail.deliver_now
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def subject(alerts)
|
|
34
|
+
if alerts.size == 1
|
|
35
|
+
"#{@subject_prefix} #{alerts.first.title}"
|
|
36
|
+
else
|
|
37
|
+
"#{@subject_prefix} #{alerts.size} alerts"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def body(alerts)
|
|
42
|
+
alerts.map do |alert|
|
|
43
|
+
lines = ["#{alert.emoji} #{alert.title}", alert.message]
|
|
44
|
+
alert.context.each { |k, v| lines << " #{k}: #{v}" }
|
|
45
|
+
lines.join("\n")
|
|
46
|
+
end.join("\n\n")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueuePulse
|
|
4
|
+
module Notifiers
|
|
5
|
+
# Posts alerts to a Slack Incoming Webhook. Payload building is separated
|
|
6
|
+
# from delivery so it can be unit-tested without the network.
|
|
7
|
+
class Slack < Base
|
|
8
|
+
def initialize(webhook_url:)
|
|
9
|
+
super()
|
|
10
|
+
@webhook_url = webhook_url
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def deliver(alerts)
|
|
14
|
+
return if alerts.empty?
|
|
15
|
+
|
|
16
|
+
post_json(@webhook_url, payload(alerts))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Hash] the Slack message body for these alerts
|
|
20
|
+
def payload(alerts)
|
|
21
|
+
{ blocks: alerts.flat_map { |alert| blocks_for(alert) } }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def blocks_for(alert)
|
|
27
|
+
detail = alert.context.map { |k, v| "*#{k}:* #{v}" }.join("\n")
|
|
28
|
+
[
|
|
29
|
+
{
|
|
30
|
+
type: "section",
|
|
31
|
+
text: { type: "mrkdwn", text: "#{alert.emoji} *#{alert.title}*\n#{alert.message}" }
|
|
32
|
+
},
|
|
33
|
+
({ type: "context", elements: [{ type: "mrkdwn", text: detail }] } unless detail.empty?)
|
|
34
|
+
].compact
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueuePulse
|
|
4
|
+
module Notifiers
|
|
5
|
+
# Collects delivered alerts in memory. For use in tests and local dev.
|
|
6
|
+
class Test < Base
|
|
7
|
+
attr_reader :delivered
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
super()
|
|
11
|
+
@delivered = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def deliver(alerts)
|
|
15
|
+
@delivered.concat(alerts)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def clear
|
|
19
|
+
@delivered.clear
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueuePulse
|
|
4
|
+
module Notifiers
|
|
5
|
+
# Posts alerts as JSON to an arbitrary endpoint. Useful for piping into your
|
|
6
|
+
# own systems, PagerDuty Events API, Discord, etc.
|
|
7
|
+
class Webhook < Base
|
|
8
|
+
def initialize(url:, headers: {})
|
|
9
|
+
super()
|
|
10
|
+
@url = url
|
|
11
|
+
@headers = headers
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def deliver(alerts)
|
|
15
|
+
return if alerts.empty?
|
|
16
|
+
|
|
17
|
+
post_json(@url, payload(alerts), headers: @headers)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def payload(alerts)
|
|
21
|
+
{
|
|
22
|
+
source: "queue_pulse",
|
|
23
|
+
version: QueuePulse::VERSION,
|
|
24
|
+
alerts: alerts.map(&:to_h)
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueuePulse
|
|
4
|
+
# Optional background poller. Runs QueuePulse.check! every poll_interval
|
|
5
|
+
# seconds in a daemon thread. Opt-in (call QueuePulse.start_poller!) because
|
|
6
|
+
# most apps prefer scheduling CheckJob via Solid Queue recurring tasks.
|
|
7
|
+
class Poller
|
|
8
|
+
def initialize(config)
|
|
9
|
+
@config = config
|
|
10
|
+
@thread = nil
|
|
11
|
+
@running = false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def start
|
|
15
|
+
return self if @running
|
|
16
|
+
|
|
17
|
+
@running = true
|
|
18
|
+
@thread = Thread.new do
|
|
19
|
+
Thread.current.name = "queue_pulse-poller"
|
|
20
|
+
loop do
|
|
21
|
+
break unless @running
|
|
22
|
+
|
|
23
|
+
QueuePulse.check!
|
|
24
|
+
sleep(@config.poll_interval)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
@config.logger.info("[QueuePulse] poller started (every #{@config.poll_interval}s)")
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def stop
|
|
32
|
+
@running = false
|
|
33
|
+
@thread&.wakeup
|
|
34
|
+
@thread = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def running?
|
|
38
|
+
@running
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
def start_poller!
|
|
44
|
+
@poller ||= Poller.new(config)
|
|
45
|
+
@poller.start
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def stop_poller!
|
|
49
|
+
@poller&.stop
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueuePulse
|
|
4
|
+
# Wires the gem into a Rails app: loads the rake tasks. The background poller
|
|
5
|
+
# is left opt-in (QueuePulse.start_poller!) to avoid surprising thread
|
|
6
|
+
# behaviour in web processes.
|
|
7
|
+
class Railtie < Rails::Railtie
|
|
8
|
+
rake_tasks do
|
|
9
|
+
load File.expand_path("../tasks/queue_pulse.rake", __dir__)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueuePulse
|
|
4
|
+
class Source
|
|
5
|
+
# Reads the real Solid Queue tables, read-only. All access goes through the
|
|
6
|
+
# solid_queue gem's own ActiveRecord models when present. Every method is
|
|
7
|
+
# defensive: if the schema isn't there yet, #available? returns false and
|
|
8
|
+
# the monitor skips all checks (Requirements US-2.4, US-7.5).
|
|
9
|
+
class SolidQueue < Source
|
|
10
|
+
def available?
|
|
11
|
+
return false unless defined?(::SolidQueue::Job)
|
|
12
|
+
|
|
13
|
+
::SolidQueue::Job.connection.data_source_exists?("solid_queue_failed_executions")
|
|
14
|
+
rescue StandardError
|
|
15
|
+
false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def failed_executions_since(since_id, limit:)
|
|
19
|
+
::SolidQueue::FailedExecution
|
|
20
|
+
.where("id > ?", since_id)
|
|
21
|
+
.order(:id)
|
|
22
|
+
.limit(limit)
|
|
23
|
+
.includes(:job)
|
|
24
|
+
.map do |fe|
|
|
25
|
+
job = fe.job
|
|
26
|
+
FailedRecord.new(
|
|
27
|
+
id: fe.id,
|
|
28
|
+
job_class: job&.class_name || "Unknown",
|
|
29
|
+
queue_name: job&.queue_name || "unknown",
|
|
30
|
+
error_message: extract_message(fe),
|
|
31
|
+
failed_at: fe.created_at
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def max_failed_execution_id
|
|
37
|
+
::SolidQueue::FailedExecution.maximum(:id)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def failed_count_since(since_id)
|
|
41
|
+
::SolidQueue::FailedExecution.where("id > ?", since_id).count
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def queue_stats
|
|
45
|
+
# Use ActiveRecord calculations (not a raw MIN pluck) so created_at comes
|
|
46
|
+
# back as a properly time-zone-cast Time, not a naive DB string. Parsing
|
|
47
|
+
# the string ourselves would misread UTC timestamps as local time and
|
|
48
|
+
# invent a timezone-sized latency (e.g. +9h in JST).
|
|
49
|
+
depths = ::SolidQueue::ReadyExecution.group(:queue_name).count
|
|
50
|
+
oldest = ::SolidQueue::ReadyExecution.group(:queue_name).minimum(:created_at)
|
|
51
|
+
depths.map do |queue_name, depth|
|
|
52
|
+
QueueStat.new(queue_name: queue_name, depth: depth.to_i, oldest_enqueued_at: oldest[queue_name])
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def stuck_claimed_executions(older_than:)
|
|
57
|
+
cutoff = now - older_than
|
|
58
|
+
::SolidQueue::ClaimedExecution
|
|
59
|
+
.where("created_at < ?", cutoff)
|
|
60
|
+
.includes(:job)
|
|
61
|
+
.map do |ce|
|
|
62
|
+
job = ce.job
|
|
63
|
+
ClaimedRecord.new(
|
|
64
|
+
job_id: ce.job_id,
|
|
65
|
+
job_class: job&.class_name || "Unknown",
|
|
66
|
+
queue_name: job&.queue_name || "unknown",
|
|
67
|
+
claimed_at: ce.created_at
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def live_process_count(heartbeat_within:)
|
|
73
|
+
cutoff = now - heartbeat_within
|
|
74
|
+
::SolidQueue::Process.where("last_heartbeat_at >= ?", cutoff).count
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def total_process_count
|
|
78
|
+
::SolidQueue::Process.count
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def extract_message(failed_execution)
|
|
84
|
+
# Solid Queue stores a serialized error; prefer its parsed accessors,
|
|
85
|
+
# fall back to the first line of the raw error string.
|
|
86
|
+
if failed_execution.respond_to?(:exception_class) && failed_execution.exception_class
|
|
87
|
+
msg = failed_execution.respond_to?(:message) ? failed_execution.message : nil
|
|
88
|
+
return "#{failed_execution.exception_class}: #{msg}".strip[0, 500]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
failed_execution.error.to_s.lines.first.to_s.strip[0, 500]
|
|
92
|
+
rescue StandardError
|
|
93
|
+
"(error details unavailable)"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueuePulse
|
|
4
|
+
# A Source abstracts every read of Solid Queue's tables. Production uses
|
|
5
|
+
# Source::SolidQueue; tests use Source::Fake. Checks depend only on this
|
|
6
|
+
# interface, so the core logic is verifiable without a real database.
|
|
7
|
+
class Source
|
|
8
|
+
# --- Value records returned to checks -------------------------------------
|
|
9
|
+
FailedRecord = Struct.new(:id, :job_class, :queue_name, :error_message, :failed_at, keyword_init: true)
|
|
10
|
+
QueueStat = Struct.new(:queue_name, :depth, :oldest_enqueued_at, keyword_init: true)
|
|
11
|
+
ClaimedRecord = Struct.new(:job_id, :job_class, :queue_name, :claimed_at, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
# Is the Solid Queue schema present and readable? When false, all checks
|
|
14
|
+
# are skipped (the app may simply not have Solid Queue set up yet).
|
|
15
|
+
def available?
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def now
|
|
20
|
+
Time.now
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Array<FailedRecord>] failed executions with id > since_id
|
|
24
|
+
def failed_executions_since(_since_id, limit:)
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Integer, nil] the largest failed-execution id currently present
|
|
29
|
+
def max_failed_execution_id
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Integer] number of failed executions with id > since_id
|
|
34
|
+
def failed_count_since(_since_id)
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Array<QueueStat>] per-queue ready depth and oldest enqueue time
|
|
39
|
+
def queue_stats
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Array<ClaimedRecord>] jobs claimed before (now - older_than secs)
|
|
44
|
+
def stuck_claimed_executions(older_than:)
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Number of processes whose heartbeat is within `heartbeat_within` seconds.
|
|
49
|
+
def live_process_count(heartbeat_within:)
|
|
50
|
+
raise NotImplementedError
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Total processes ever registered (used to distinguish "never started" from
|
|
54
|
+
# "all died").
|
|
55
|
+
def total_process_count
|
|
56
|
+
raise NotImplementedError
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# In-memory implementation for tests and local experiments.
|
|
60
|
+
class Fake < Source
|
|
61
|
+
attr_accessor :available, :clock
|
|
62
|
+
|
|
63
|
+
def initialize(now: Time.now)
|
|
64
|
+
super()
|
|
65
|
+
@available = true
|
|
66
|
+
@clock = now
|
|
67
|
+
@failed = []
|
|
68
|
+
@queue_stats = []
|
|
69
|
+
@claimed = []
|
|
70
|
+
@processes = [] # array of heartbeat Times
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def available? = @available
|
|
74
|
+
def now = @clock
|
|
75
|
+
|
|
76
|
+
# --- test setters --------------------------------------------------------
|
|
77
|
+
def add_failed(id:, job_class:, queue_name:, error_message:, failed_at: @clock)
|
|
78
|
+
@failed << FailedRecord.new(id: id, job_class: job_class, queue_name: queue_name,
|
|
79
|
+
error_message: error_message, failed_at: failed_at)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def set_queue(queue_name, depth:, oldest_enqueued_at: nil)
|
|
83
|
+
@queue_stats.reject! { |q| q.queue_name == queue_name }
|
|
84
|
+
@queue_stats << QueueStat.new(queue_name: queue_name, depth: depth, oldest_enqueued_at: oldest_enqueued_at)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def add_claimed(job_id:, job_class:, queue_name:, claimed_at:)
|
|
88
|
+
@claimed << ClaimedRecord.new(job_id: job_id, job_class: job_class, queue_name: queue_name, claimed_at: claimed_at)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def add_process(last_heartbeat_at:)
|
|
92
|
+
@processes << last_heartbeat_at
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# --- Source interface ----------------------------------------------------
|
|
96
|
+
def failed_executions_since(since_id, limit:)
|
|
97
|
+
@failed.select { |f| f.id > since_id }.sort_by(&:id).first(limit)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def max_failed_execution_id
|
|
101
|
+
@failed.map(&:id).max
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def failed_count_since(since_id)
|
|
105
|
+
@failed.count { |f| f.id > since_id }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def queue_stats
|
|
109
|
+
@queue_stats
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def stuck_claimed_executions(older_than:)
|
|
113
|
+
cutoff = @clock - older_than
|
|
114
|
+
@claimed.select { |c| c.claimed_at < cutoff }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def live_process_count(heartbeat_within:)
|
|
118
|
+
cutoff = @clock - heartbeat_within
|
|
119
|
+
@processes.count { |hb| hb >= cutoff }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def total_process_count
|
|
123
|
+
@processes.size
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueuePulse
|
|
4
|
+
# Tiny wrapper over the configured cache for the two pieces of state QueuePulse
|
|
5
|
+
# needs: the failed-execution high-water mark, and per-fingerprint cooldowns.
|
|
6
|
+
#
|
|
7
|
+
# Backed by Rails.cache (or MemoryStore fallback). If the cache evicts our
|
|
8
|
+
# keys we fail safe: at worst a duplicate alert, never a crash. (Requirements
|
|
9
|
+
# US-2.2, US-3.3, non-functional #3.)
|
|
10
|
+
class State
|
|
11
|
+
NAMESPACE = "queue_pulse"
|
|
12
|
+
HWM_KEY = "#{NAMESPACE}:failed_high_water_mark"
|
|
13
|
+
|
|
14
|
+
def initialize(cache)
|
|
15
|
+
@cache = cache
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def failed_high_water_mark
|
|
19
|
+
value = @cache.read(HWM_KEY)
|
|
20
|
+
value && Integer(value)
|
|
21
|
+
rescue ArgumentError, TypeError
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def set_failed_high_water_mark(id)
|
|
26
|
+
@cache.write(HWM_KEY, id.to_i)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# True if this fingerprint was alerted within its cooldown window.
|
|
30
|
+
def alerted_recently?(fingerprint)
|
|
31
|
+
!@cache.read(cooldown_key(fingerprint)).nil?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def mark_alerted(fingerprint, ttl)
|
|
35
|
+
@cache.write(cooldown_key(fingerprint), 1, expires_in: ttl)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def cooldown_key(fingerprint)
|
|
41
|
+
"#{NAMESPACE}:cooldown:#{fingerprint}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|