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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueuePulse
4
+ VERSION = "0.1.0"
5
+ end