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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ce1008bbb8305bb0a7c884df8ca88bcd44484c7f177ec1a400eee970c8387721
|
|
4
|
+
data.tar.gz: f7e29e0722bd4278169e254651b5266dbda28b77248cd928579b7318a546cfca
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 54437eb5f66dba1c5ea6db4d3208c1b18288c71656690a6f8212bb2a9d67a99d393b4e87da6e668425f5309b42326be3b90d40ddb477c64c5e2eb7d0f1ef1970
|
|
7
|
+
data.tar.gz: d75c324f3bc757080309e7b053365ebb79ff1fa77b4c94f2bf2a549d43d5743464f9dd54f3c80f294538dc55cb9a434a9a64e5f84c4aa61f9ea6823cd2a1914a
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
This project adheres to [Semantic Versioning](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-06-09
|
|
9
|
+
Initial open-source release (Phase 1 โ the free core).
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- Read-only monitoring of Solid Queue with **no migration and no extra service**.
|
|
13
|
+
- Five checks: job failures (with burst-collapsing), queue latency, queue depth,
|
|
14
|
+
stuck (long-running) jobs, and worker-liveness (dead-worker) detection.
|
|
15
|
+
- Notifiers: Slack, generic Webhook, Email (ActionMailer), plus a Test notifier.
|
|
16
|
+
- Alert deduplication via a failed-execution high-water mark and per-condition cooldowns,
|
|
17
|
+
backed by `Rails.cache` (with an in-process fallback).
|
|
18
|
+
- Execution via `QueuePulse.check!`, `QueuePulse::CheckJob` (for Solid Queue recurring tasks),
|
|
19
|
+
a `queue_pulse:check` rake task, and an opt-in in-process poller.
|
|
20
|
+
- Full exception isolation: a failing check or notifier never propagates to the host app.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 QueuePulse
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# QueuePulse
|
|
2
|
+
|
|
3
|
+
**Know the moment your Solid Queue jobs break โ without running Datadog.**
|
|
4
|
+
|
|
5
|
+
[Solid Queue](https://github.com/rails/solid_queue) is the default Active Job backend in Rails 8.
|
|
6
|
+
Its dashboard, [Mission Control Jobs](https://github.com/rails/mission_control-jobs), is great for
|
|
7
|
+
*looking* at jobs โ but it has **no alerting** and **no historical metrics**. So today you either
|
|
8
|
+
find out your jobs are failing when a customer emails you, or you bolt on a heavyweight APM
|
|
9
|
+
(Datadog/New Relic) that's overkill for a small app.
|
|
10
|
+
|
|
11
|
+
QueuePulse fills that gap. It reads Solid Queue's existing tables (read-only, **no migration, no extra
|
|
12
|
+
service**) and pings you the moment something goes wrong:
|
|
13
|
+
|
|
14
|
+
- ๐ด **Job failures** โ alerted the instant a job lands in `failed_executions`
|
|
15
|
+
- ๐ **Queue latency** โ oldest job has been waiting too long (work is backing up)
|
|
16
|
+
- ๐ **Queue depth** โ a queue is piling up beyond your threshold
|
|
17
|
+
- ๐ **Stuck jobs** โ a job has been running far longer than it should
|
|
18
|
+
- ๐ด **Dead workers** โ no worker/dispatcher heartbeat = nothing is processing jobs
|
|
19
|
+
|
|
20
|
+
Delivered to **Slack, email, or any webhook**.
|
|
21
|
+
|
|
22
|
+
> Status: early. The free gem (this repo) is the open-source core. A hosted dashboard with
|
|
23
|
+
> historical metrics and AI failure summaries is on the roadmap โ
|
|
24
|
+
> **[join the waitlist ยป](https://queue-pulse.vercel.app)**.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
# Gemfile
|
|
30
|
+
gem "queue_pulse"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
bundle install
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Configure
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
# config/initializers/queue_pulse.rb
|
|
41
|
+
QueuePulse.configure do |config|
|
|
42
|
+
config.add_notifier QueuePulse::Notifiers::Slack.new(webhook_url: ENV["SLACK_WEBHOOK_URL"])
|
|
43
|
+
|
|
44
|
+
# Optional โ every setting has a sensible default:
|
|
45
|
+
config.queue_latency_threshold = 300 # seconds a job may wait before alerting
|
|
46
|
+
config.queue_depth_threshold = 1_000 # ready jobs per queue before alerting
|
|
47
|
+
config.stuck_job_threshold = 600 # seconds a running job may take before alerting
|
|
48
|
+
config.alert_cooldown = 900 # seconds before re-alerting the same condition
|
|
49
|
+
config.environment_label = Rails.env
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Run the checks
|
|
54
|
+
|
|
55
|
+
Schedule it with Solid Queue's own recurring tasks (recommended):
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
# config/recurring.yml
|
|
59
|
+
queue_pulse_check:
|
|
60
|
+
class: QueuePulse::CheckJob
|
|
61
|
+
schedule: every minute
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Or run on demand:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
bin/rails queue_pulse:check
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Or run an in-process poller (opt-in):
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
QueuePulse.start_poller! # checks every config.poll_interval seconds
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Notifiers
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
QueuePulse::Notifiers::Slack.new(webhook_url: "https://hooks.slack.com/...")
|
|
80
|
+
QueuePulse::Notifiers::Webhook.new(url: "https://example.com/hook", headers: { "Authorization" => "Bearer ..." })
|
|
81
|
+
QueuePulse::Notifiers::Email.new(to: "ops@example.com")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Add your own by subclassing `QueuePulse::Notifiers::Base` and implementing `#deliver(alerts)`.
|
|
85
|
+
|
|
86
|
+
## Design principles
|
|
87
|
+
|
|
88
|
+
- **No migration, no extra service.** Reads Solid Queue tables directly; dedupe state lives in `Rails.cache`.
|
|
89
|
+
- **Safe by default.** Read-only queries, every check and notifier is exception-isolated โ QueuePulse can never take down your app. Raw job arguments are never sent unless you opt in.
|
|
90
|
+
- **Quiet.** Cooldowns and burst-collapsing mean you get signal, not spam.
|
|
91
|
+
|
|
92
|
+
## Requirements
|
|
93
|
+
|
|
94
|
+
Ruby โฅ 3.1, Rails โฅ 7.1, solid_queue โฅ 1.0.
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
bundle install
|
|
100
|
+
rake test
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT โ see [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueuePulse
|
|
4
|
+
# An immutable description of something worth notifying about.
|
|
5
|
+
#
|
|
6
|
+
# fingerprint is the dedupe/cooldown key: alerts sharing a fingerprint are
|
|
7
|
+
# considered "the same ongoing condition" and are rate-limited together.
|
|
8
|
+
class Alert
|
|
9
|
+
SEVERITIES = %i[info warning critical].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :type, :severity, :title, :message, :context, :fingerprint
|
|
12
|
+
|
|
13
|
+
def initialize(type:, severity:, title:, message:, fingerprint:, context: {})
|
|
14
|
+
@type = type.to_sym
|
|
15
|
+
@severity = severity.to_sym
|
|
16
|
+
unless SEVERITIES.include?(@severity)
|
|
17
|
+
raise ArgumentError, "severity must be one of #{SEVERITIES.inspect}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
@title = title
|
|
21
|
+
@message = message
|
|
22
|
+
@context = context.freeze
|
|
23
|
+
@fingerprint = fingerprint
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_h
|
|
28
|
+
{
|
|
29
|
+
type: type,
|
|
30
|
+
severity: severity,
|
|
31
|
+
title: title,
|
|
32
|
+
message: message,
|
|
33
|
+
context: context,
|
|
34
|
+
fingerprint: fingerprint
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
SEVERITY_EMOJI = { info: "๐ต", warning: "๐ ", critical: "๐ด" }.freeze
|
|
39
|
+
|
|
40
|
+
def emoji
|
|
41
|
+
SEVERITY_EMOJI.fetch(severity, "โช๏ธ")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueuePulse
|
|
4
|
+
# Schedule this via Solid Queue recurring tasks to run checks periodically:
|
|
5
|
+
#
|
|
6
|
+
# # config/recurring.yml
|
|
7
|
+
# queue_pulse_check:
|
|
8
|
+
# class: QueuePulse::CheckJob
|
|
9
|
+
# schedule: every minute
|
|
10
|
+
class CheckJob < ActiveJob::Base
|
|
11
|
+
queue_as :queue_pulse
|
|
12
|
+
|
|
13
|
+
def perform
|
|
14
|
+
QueuePulse.check!
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueuePulse
|
|
4
|
+
module Checks
|
|
5
|
+
# Checks are pure-ish: given (config, source, state) they return Alerts.
|
|
6
|
+
# The only side effect they own is updating dedupe/cooldown state at the
|
|
7
|
+
# moment they decide to emit (keeps cooldown logic next to the data that
|
|
8
|
+
# produces it). Delivery is the Monitor's job.
|
|
9
|
+
class Base
|
|
10
|
+
def initialize(config, source, state)
|
|
11
|
+
@config = config
|
|
12
|
+
@source = source
|
|
13
|
+
@state = state
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [Array<QueuePulse::Alert>]
|
|
17
|
+
def call
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
attr_reader :config, :source, :state
|
|
24
|
+
|
|
25
|
+
# Emit only if this fingerprint is outside its cooldown window, marking it
|
|
26
|
+
# so it won't re-alert until the window passes (Requirements US-3.3/3.4).
|
|
27
|
+
def with_cooldown(fingerprint)
|
|
28
|
+
return nil if state.alerted_recently?(fingerprint)
|
|
29
|
+
|
|
30
|
+
alert = yield
|
|
31
|
+
state.mark_alerted(fingerprint, config.alert_cooldown) if alert
|
|
32
|
+
alert
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def env_suffix
|
|
36
|
+
config.environment_label ? " [#{config.environment_label}]" : ""
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueuePulse
|
|
4
|
+
module Checks
|
|
5
|
+
# Detects newly failed jobs since the last pass using a high-water mark on
|
|
6
|
+
# solid_queue_failed_executions.id. On first ever run it establishes the
|
|
7
|
+
# baseline silently (so installing the gem doesn't replay history).
|
|
8
|
+
# Above failure_burst_threshold it collapses into a single summary alert.
|
|
9
|
+
# (Requirements US-2.)
|
|
10
|
+
class Failures < Base
|
|
11
|
+
def call
|
|
12
|
+
hwm = state.failed_high_water_mark
|
|
13
|
+
|
|
14
|
+
if hwm.nil?
|
|
15
|
+
baseline = source.max_failed_execution_id || 0
|
|
16
|
+
state.set_failed_high_water_mark(baseline)
|
|
17
|
+
return []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
burst = config.failure_burst_threshold
|
|
21
|
+
recent = source.failed_executions_since(hwm, limit: burst + 1)
|
|
22
|
+
return [] if recent.empty?
|
|
23
|
+
|
|
24
|
+
new_hwm = recent.map(&:id).max
|
|
25
|
+
|
|
26
|
+
if recent.size > burst
|
|
27
|
+
total = source.failed_count_since(hwm)
|
|
28
|
+
state.set_failed_high_water_mark(source.max_failed_execution_id || new_hwm)
|
|
29
|
+
[burst_alert(total)]
|
|
30
|
+
else
|
|
31
|
+
state.set_failed_high_water_mark(new_hwm)
|
|
32
|
+
recent.map { |f| failure_alert(f) }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def failure_alert(failure)
|
|
39
|
+
Alert.new(
|
|
40
|
+
type: :job_failed,
|
|
41
|
+
severity: :critical,
|
|
42
|
+
title: "Job failed: #{failure.job_class}#{env_suffix}",
|
|
43
|
+
message: "#{failure.job_class} on queue \"#{failure.queue_name}\" failed: #{failure.error_message}",
|
|
44
|
+
fingerprint: "job_failed:#{failure.id}",
|
|
45
|
+
context: {
|
|
46
|
+
job_class: failure.job_class,
|
|
47
|
+
queue_name: failure.queue_name,
|
|
48
|
+
error: failure.error_message,
|
|
49
|
+
failed_at: failure.failed_at
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def burst_alert(total)
|
|
55
|
+
Alert.new(
|
|
56
|
+
type: :job_failure_burst,
|
|
57
|
+
severity: :critical,
|
|
58
|
+
title: "#{total} jobs failed#{env_suffix}",
|
|
59
|
+
message: "#{total} background jobs failed since the last check. " \
|
|
60
|
+
"Open Solid Queue / Mission Control to inspect.",
|
|
61
|
+
fingerprint: "job_failure_burst",
|
|
62
|
+
context: { count: total }
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueuePulse
|
|
4
|
+
module Checks
|
|
5
|
+
# Alerts when a queue's ready depth exceeds queue_depth_threshold.
|
|
6
|
+
# Cooldown is per-queue. (Requirements US-3.2, US-3.3.)
|
|
7
|
+
class QueueDepth < Base
|
|
8
|
+
def call
|
|
9
|
+
threshold = config.queue_depth_threshold
|
|
10
|
+
|
|
11
|
+
source.queue_stats.filter_map do |stat|
|
|
12
|
+
next if stat.depth < threshold
|
|
13
|
+
|
|
14
|
+
with_cooldown("queue_depth:#{stat.queue_name}") do
|
|
15
|
+
depth_alert(stat)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def depth_alert(stat)
|
|
23
|
+
Alert.new(
|
|
24
|
+
type: :queue_depth,
|
|
25
|
+
severity: :warning,
|
|
26
|
+
title: "Queue \"#{stat.queue_name}\" is backing up#{env_suffix}",
|
|
27
|
+
message: "\"#{stat.queue_name}\" has #{stat.depth} jobs waiting " \
|
|
28
|
+
"(threshold #{config.queue_depth_threshold}).",
|
|
29
|
+
fingerprint: "queue_depth:#{stat.queue_name}",
|
|
30
|
+
context: { queue_name: stat.queue_name, depth: stat.depth }
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueuePulse
|
|
4
|
+
module Checks
|
|
5
|
+
# Alerts when the oldest ready job in a queue has been waiting longer than
|
|
6
|
+
# queue_latency_threshold โ i.e. work is backing up before users feel it.
|
|
7
|
+
# Cooldown is per-queue. (Requirements US-3.1, US-3.3, US-3.4.)
|
|
8
|
+
class QueueLatency < Base
|
|
9
|
+
def call
|
|
10
|
+
threshold = config.queue_latency_threshold
|
|
11
|
+
now = source.now
|
|
12
|
+
|
|
13
|
+
source.queue_stats.filter_map do |stat|
|
|
14
|
+
next unless stat.oldest_enqueued_at
|
|
15
|
+
|
|
16
|
+
latency = (now - stat.oldest_enqueued_at).to_i
|
|
17
|
+
next if latency < threshold
|
|
18
|
+
|
|
19
|
+
with_cooldown("queue_latency:#{stat.queue_name}") do
|
|
20
|
+
latency_alert(stat, latency)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def latency_alert(stat, latency)
|
|
28
|
+
Alert.new(
|
|
29
|
+
type: :queue_latency,
|
|
30
|
+
severity: :warning,
|
|
31
|
+
title: "Queue \"#{stat.queue_name}\" is delayed#{env_suffix}",
|
|
32
|
+
message: "Oldest job in \"#{stat.queue_name}\" has waited #{latency}s " \
|
|
33
|
+
"(threshold #{config.queue_latency_threshold}s), #{stat.depth} jobs waiting.",
|
|
34
|
+
fingerprint: "queue_latency:#{stat.queue_name}",
|
|
35
|
+
context: { queue_name: stat.queue_name, latency_seconds: latency, depth: stat.depth }
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueuePulse
|
|
4
|
+
module Checks
|
|
5
|
+
# Alerts on jobs that have been claimed (running) longer than
|
|
6
|
+
# stuck_job_threshold. Cooldown is per job_id so each stuck job alerts once
|
|
7
|
+
# per window. (Requirements US-4.)
|
|
8
|
+
class StuckJobs < Base
|
|
9
|
+
def call
|
|
10
|
+
threshold = config.stuck_job_threshold
|
|
11
|
+
now = source.now
|
|
12
|
+
|
|
13
|
+
source.stuck_claimed_executions(older_than: threshold).filter_map do |claimed|
|
|
14
|
+
running = (now - claimed.claimed_at).to_i
|
|
15
|
+
|
|
16
|
+
with_cooldown("stuck_job:#{claimed.job_id}") do
|
|
17
|
+
stuck_alert(claimed, running)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def stuck_alert(claimed, running)
|
|
25
|
+
Alert.new(
|
|
26
|
+
type: :stuck_job,
|
|
27
|
+
severity: :warning,
|
|
28
|
+
title: "Job stuck: #{claimed.job_class}#{env_suffix}",
|
|
29
|
+
message: "#{claimed.job_class} on \"#{claimed.queue_name}\" has been running #{running}s " \
|
|
30
|
+
"(threshold #{config.stuck_job_threshold}s). It may be hung.",
|
|
31
|
+
fingerprint: "stuck_job:#{claimed.job_id}",
|
|
32
|
+
context: {
|
|
33
|
+
job_id: claimed.job_id,
|
|
34
|
+
job_class: claimed.job_class,
|
|
35
|
+
queue_name: claimed.queue_name,
|
|
36
|
+
running_seconds: running
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueuePulse
|
|
4
|
+
module Checks
|
|
5
|
+
# Alerts when no worker/dispatcher process has a fresh heartbeat โ meaning
|
|
6
|
+
# nothing is processing jobs. Critical, because the queue silently stops.
|
|
7
|
+
# (Requirements US-5.) Only fires when at least one process was ever
|
|
8
|
+
# registered, to avoid false alarms before the first boot.
|
|
9
|
+
class WorkerLiveness < Base
|
|
10
|
+
def call
|
|
11
|
+
return [] if source.total_process_count.to_i.zero?
|
|
12
|
+
|
|
13
|
+
live = source.live_process_count(heartbeat_within: config.worker_heartbeat_threshold)
|
|
14
|
+
return [] if live.positive?
|
|
15
|
+
|
|
16
|
+
alert = with_cooldown("workers_down") { workers_down_alert }
|
|
17
|
+
Array(alert)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def workers_down_alert
|
|
23
|
+
Alert.new(
|
|
24
|
+
type: :workers_down,
|
|
25
|
+
severity: :critical,
|
|
26
|
+
title: "No live Solid Queue workers#{env_suffix}",
|
|
27
|
+
message: "No worker/dispatcher has sent a heartbeat in the last " \
|
|
28
|
+
"#{config.worker_heartbeat_threshold}s. Background jobs are NOT being processed.",
|
|
29
|
+
fingerprint: "workers_down",
|
|
30
|
+
context: { heartbeat_threshold: config.worker_heartbeat_threshold }
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueuePulse
|
|
4
|
+
# Holds all tunable settings. Sensible defaults mean a user only has to add a
|
|
5
|
+
# notifier to get value. See docs/requirements.md (US-1, US-3).
|
|
6
|
+
class Configuration
|
|
7
|
+
# All checks shipped in Phase 1, in evaluation order.
|
|
8
|
+
ALL_CHECKS = %i[failures queue_latency queue_depth stuck_jobs worker_liveness].freeze
|
|
9
|
+
|
|
10
|
+
attr_accessor :enabled,
|
|
11
|
+
:poll_interval,
|
|
12
|
+
:queue_latency_threshold,
|
|
13
|
+
:queue_depth_threshold,
|
|
14
|
+
:stuck_job_threshold,
|
|
15
|
+
:worker_heartbeat_threshold,
|
|
16
|
+
:alert_cooldown,
|
|
17
|
+
:failure_burst_threshold,
|
|
18
|
+
:check_timeout,
|
|
19
|
+
:notifiers,
|
|
20
|
+
:enabled_checks,
|
|
21
|
+
:environment_label,
|
|
22
|
+
:include_arguments,
|
|
23
|
+
:logger
|
|
24
|
+
|
|
25
|
+
attr_writer :cache
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@enabled = true
|
|
29
|
+
@poll_interval = 30 # seconds (Railtie poller)
|
|
30
|
+
@queue_latency_threshold = 300 # seconds a job may wait before alerting
|
|
31
|
+
@queue_depth_threshold = 1_000 # ready jobs per queue before alerting
|
|
32
|
+
@stuck_job_threshold = 600 # seconds a claimed job may run before alerting
|
|
33
|
+
@worker_heartbeat_threshold = 120 # seconds before a process is "dead"
|
|
34
|
+
@alert_cooldown = 900 # seconds to suppress duplicate state alerts
|
|
35
|
+
@failure_burst_threshold = 25 # collapse into a summary above this many
|
|
36
|
+
@check_timeout = 25 # seconds; abort a slow pass
|
|
37
|
+
@notifiers = []
|
|
38
|
+
@enabled_checks = ALL_CHECKS.dup
|
|
39
|
+
@environment_label = default_environment
|
|
40
|
+
@include_arguments = false # never send raw job arguments by default
|
|
41
|
+
@logger = default_logger
|
|
42
|
+
@cache = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def add_notifier(notifier)
|
|
46
|
+
@notifiers << notifier
|
|
47
|
+
notifier
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def check_enabled?(name)
|
|
51
|
+
@enabled_checks.include?(name)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Cache store for dedupe/cooldown state. Prefers Rails.cache, falls back to
|
|
55
|
+
# an in-process MemoryStore so the gem works even without a configured cache.
|
|
56
|
+
def cache
|
|
57
|
+
@cache ||= rails_cache || MemoryStore.new
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def rails_cache
|
|
63
|
+
return nil unless defined?(Rails)
|
|
64
|
+
|
|
65
|
+
store = Rails.respond_to?(:cache) ? Rails.cache : nil
|
|
66
|
+
# A NullStore would silently drop our dedupe state โ prefer MemoryStore.
|
|
67
|
+
return nil if store.nil? || store.class.name.to_s.include?("NullStore")
|
|
68
|
+
|
|
69
|
+
store
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def default_environment
|
|
73
|
+
return ENV["RAILS_ENV"] if ENV["RAILS_ENV"]
|
|
74
|
+
return Rails.env.to_s if defined?(Rails) && Rails.respond_to?(:env)
|
|
75
|
+
|
|
76
|
+
ENV["RACK_ENV"] || "development"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def default_logger
|
|
80
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
81
|
+
Rails.logger
|
|
82
|
+
else
|
|
83
|
+
Logger.new($stdout).tap { |l| l.level = Logger::INFO }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueuePulse
|
|
4
|
+
# Minimal in-process cache used as a fallback when Rails.cache is unavailable
|
|
5
|
+
# (e.g. plain Ruby tests, or apps with no cache store configured).
|
|
6
|
+
#
|
|
7
|
+
# Implements the small subset of the ActiveSupport::Cache interface QueuePulse
|
|
8
|
+
# relies on: #read, #write (with :expires_in), #delete. Thread-safe.
|
|
9
|
+
class MemoryStore
|
|
10
|
+
Entry = Struct.new(:value, :expires_at)
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@data = {}
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def read(key)
|
|
18
|
+
@mutex.synchronize do
|
|
19
|
+
entry = @data[key]
|
|
20
|
+
return nil unless entry
|
|
21
|
+
if entry.expires_at && entry.expires_at <= monotonic_now
|
|
22
|
+
@data.delete(key)
|
|
23
|
+
return nil
|
|
24
|
+
end
|
|
25
|
+
entry.value
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def write(key, value, expires_in: nil)
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
expires_at = expires_in ? monotonic_now + expires_in : nil
|
|
32
|
+
@data[key] = Entry.new(value, expires_at)
|
|
33
|
+
end
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def delete(key)
|
|
38
|
+
@mutex.synchronize { @data.delete(key) }
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def monotonic_now
|
|
45
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|