pgbus 0.9.7 → 0.9.8

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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +119 -1
  4. data/Rakefile +10 -1
  5. data/app/helpers/pgbus/application_helper.rb +37 -0
  6. data/app/views/pgbus/processes/_processes_table.html.erb +4 -1
  7. data/config/locales/da.yml +4 -0
  8. data/config/locales/de.yml +4 -0
  9. data/config/locales/en.yml +4 -0
  10. data/config/locales/es.yml +4 -0
  11. data/config/locales/fi.yml +4 -0
  12. data/config/locales/fr.yml +4 -0
  13. data/config/locales/it.yml +4 -0
  14. data/config/locales/ja.yml +4 -0
  15. data/config/locales/nb.yml +4 -0
  16. data/config/locales/nl.yml +4 -0
  17. data/config/locales/pt.yml +4 -0
  18. data/config/locales/sv.yml +4 -0
  19. data/lib/pgbus/active_job/executor.rb +25 -4
  20. data/lib/pgbus/cli/dlq.rb +164 -0
  21. data/lib/pgbus/cli.rb +18 -1
  22. data/lib/pgbus/client/connection_health.rb +194 -0
  23. data/lib/pgbus/client.rb +592 -73
  24. data/lib/pgbus/config_loader.rb +23 -4
  25. data/lib/pgbus/configuration.rb +98 -12
  26. data/lib/pgbus/dedup_cache.rb +8 -0
  27. data/lib/pgbus/doctor.rb +250 -0
  28. data/lib/pgbus/engine.rb +15 -0
  29. data/lib/pgbus/execution_pools/async_pool.rb +7 -0
  30. data/lib/pgbus/execution_pools/thread_pool.rb +7 -0
  31. data/lib/pgbus/instrumentation.rb +1 -0
  32. data/lib/pgbus/integrations/appsignal/probe.rb +23 -1
  33. data/lib/pgbus/metrics/backend.rb +38 -0
  34. data/lib/pgbus/metrics/backends/prometheus.rb +123 -0
  35. data/lib/pgbus/metrics/backends/statsd.rb +64 -0
  36. data/lib/pgbus/metrics/prometheus_exporter.rb +34 -0
  37. data/lib/pgbus/metrics/subscriber.rb +190 -0
  38. data/lib/pgbus/metrics.rb +42 -0
  39. data/lib/pgbus/process/consumer.rb +215 -8
  40. data/lib/pgbus/process/consumer_priority.rb +34 -0
  41. data/lib/pgbus/process/dispatcher.rb +265 -41
  42. data/lib/pgbus/process/heartbeat.rb +18 -5
  43. data/lib/pgbus/process/memory_usage.rb +48 -0
  44. data/lib/pgbus/process/notify_listener.rb +26 -7
  45. data/lib/pgbus/process/notify_probe.rb +96 -0
  46. data/lib/pgbus/process/primary_validator.rb +53 -0
  47. data/lib/pgbus/process/signal_handler.rb +6 -0
  48. data/lib/pgbus/process/supervisor.rb +396 -46
  49. data/lib/pgbus/process/worker.rb +298 -35
  50. data/lib/pgbus/recurring/scheduler.rb +15 -1
  51. data/lib/pgbus/streams/turbo_broadcastable.rb +7 -5
  52. data/lib/pgbus/table_maintenance.rb +13 -2
  53. data/lib/pgbus/version.rb +1 -1
  54. data/lib/pgbus/web/data_source.rb +20 -4
  55. data/lib/pgbus/web/health_app.rb +102 -0
  56. data/lib/pgbus/web/health_server.rb +144 -0
  57. data/lib/pgbus/web/streamer/instance.rb +55 -1
  58. data/lib/pgbus/web/streamer/listener.rb +72 -9
  59. data/lib/pgbus.rb +37 -0
  60. data/lib/rubocop/cop/pgbus/no_ruby_timeout.rb +42 -0
  61. data/lib/rubocop/pgbus.rb +5 -0
  62. data/lib/tasks/pgbus_doctor.rake +12 -0
  63. metadata +18 -1
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Pgbus
6
+ module CLI
7
+ # Dead-letter queue management for headless deployments and incident
8
+ # runbooks. Every operation routes through +Web::DataSource+ so the CLI
9
+ # shares the dashboard's exact semantics — origin-queue re-enqueue,
10
+ # transactional retry, lock release on discard, sensitive-payload
11
+ # filtering — with zero raw SQL or direct PGMQ calls.
12
+ module DLQ
13
+ module_function
14
+
15
+ ROW_FORMAT = "%-10s %-32s %-28s %-8s %s"
16
+
17
+ def start(args, data_source: Pgbus::Web::DataSource.new)
18
+ subcommand = args.first
19
+ rest = args[1..] || []
20
+
21
+ case subcommand
22
+ when "list" then list(rest, data_source)
23
+ when "show" then show(rest, data_source)
24
+ when "retry" then retry_one(rest, data_source)
25
+ when "retry-all" then retry_all(data_source)
26
+ when "purge" then purge(rest, data_source)
27
+ else
28
+ warn "Unknown dlq subcommand: #{subcommand.inspect}" if subcommand
29
+ print_help
30
+ exit 1
31
+ end
32
+ end
33
+
34
+ def list(args, data_source)
35
+ options = parse_list_options(args)
36
+ messages = data_source.dlq_messages(page: options[:page], per_page: options[:per_page])
37
+
38
+ if messages.empty?
39
+ puts "No dead-letter messages."
40
+ return
41
+ end
42
+
43
+ print_list_table(messages)
44
+ puts "-" * 96
45
+ puts "Total dead-letter messages: #{data_source.dlq_total_count} " \
46
+ "(page #{options[:page]}, #{options[:per_page]} per page)"
47
+ end
48
+
49
+ def show(args, data_source)
50
+ msg_id = require_msg_id(args)
51
+ detail = data_source.dlq_message_detail(msg_id)
52
+ abort_missing(msg_id) unless detail
53
+
54
+ puts "msg_id: #{detail[:msg_id]}"
55
+ puts "dlq queue: #{detail[:queue_name]}"
56
+ puts "origin: #{origin_queue(detail[:queue_name])}"
57
+ puts "read_ct: #{detail[:read_ct]}"
58
+ puts "enqueued_at: #{detail[:enqueued_at]}"
59
+ puts "payload:"
60
+ puts Pgbus::Web::PayloadFilter.filter_json(detail[:message])
61
+ end
62
+
63
+ def retry_one(args, data_source)
64
+ msg_id = require_msg_id(args)
65
+ detail = data_source.dlq_message_detail(msg_id)
66
+ abort_missing(msg_id) unless detail
67
+
68
+ if data_source.retry_dlq_message(detail[:queue_name], detail[:msg_id])
69
+ puts "Re-enqueued msg_id #{detail[:msg_id]} to #{origin_queue(detail[:queue_name])}."
70
+ else
71
+ warn "Failed to retry msg_id #{detail[:msg_id]}."
72
+ exit 1
73
+ end
74
+ end
75
+
76
+ def retry_all(data_source)
77
+ count = data_source.retry_all_dlq
78
+ puts "Re-enqueued #{count} dead-letter message#{"s" unless count == 1}."
79
+ end
80
+
81
+ def purge(args, data_source)
82
+ if args.include?("--all")
83
+ purge_all(args, data_source)
84
+ else
85
+ purge_one(args, data_source)
86
+ end
87
+ end
88
+
89
+ def purge_one(args, data_source)
90
+ msg_id = require_msg_id(args)
91
+ detail = data_source.dlq_message_detail(msg_id)
92
+ abort_missing(msg_id) unless detail
93
+
94
+ if data_source.discard_dlq_message(detail[:queue_name], detail[:msg_id])
95
+ puts "Purged msg_id #{detail[:msg_id]} from #{detail[:queue_name]}."
96
+ else
97
+ warn "Failed to purge msg_id #{detail[:msg_id]}."
98
+ exit 1
99
+ end
100
+ end
101
+
102
+ def purge_all(args, data_source)
103
+ unless args.include?("--yes")
104
+ warn "Refusing to purge all dead-letter messages without --yes."
105
+ exit 1
106
+ end
107
+
108
+ count = data_source.discard_all_dlq
109
+ puts "Purged #{count} dead-letter message#{"s" unless count == 1}."
110
+ end
111
+
112
+ def parse_list_options(args)
113
+ options = { page: 1, per_page: 25 }
114
+ OptionParser.new do |opts|
115
+ opts.banner = "Usage: pgbus dlq list [options]"
116
+ opts.on("--page N", Integer, "Page number (default: 1)") { |v| options[:page] = v }
117
+ opts.on("--per-page N", Integer, "Messages per page (default: 25)") { |v| options[:per_page] = v }
118
+ end.parse!(args.dup)
119
+ options
120
+ end
121
+
122
+ def print_list_table(messages)
123
+ puts format(ROW_FORMAT, "MSG_ID", "DLQ QUEUE", "ORIGIN QUEUE", "READ_CT", "ENQUEUED_AT")
124
+ puts "-" * 96
125
+ messages.each do |m|
126
+ puts format(ROW_FORMAT, m[:msg_id], m[:queue_name], origin_queue(m[:queue_name]),
127
+ m[:read_ct], m[:enqueued_at])
128
+ end
129
+ end
130
+
131
+ def origin_queue(dlq_queue_name)
132
+ dlq_queue_name.delete_suffix(Pgbus::DEAD_LETTER_SUFFIX)
133
+ end
134
+
135
+ def require_msg_id(args)
136
+ msg_id = args.find { |a| a !~ /\A--/ }
137
+ unless msg_id
138
+ warn "A msg_id is required."
139
+ exit 1
140
+ end
141
+ msg_id
142
+ end
143
+
144
+ def abort_missing(msg_id)
145
+ warn "No dead-letter message found with msg_id #{msg_id}."
146
+ exit 1
147
+ end
148
+
149
+ def print_help
150
+ puts <<~HELP
151
+ Usage: pgbus dlq <subcommand> [options]
152
+
153
+ Subcommands:
154
+ list [--page N] [--per-page N] List dead-letter messages
155
+ show MSG_ID Show one message (payload filtered)
156
+ retry MSG_ID Re-enqueue a message to its origin queue
157
+ retry-all Re-enqueue every dead-letter message
158
+ purge MSG_ID Discard a single message
159
+ purge --all --yes Discard every dead-letter message
160
+ HELP
161
+ end
162
+ end
163
+ end
164
+ end
data/lib/pgbus/cli.rb CHANGED
@@ -16,8 +16,12 @@ module Pgbus
16
16
  show_status
17
17
  when "queues"
18
18
  list_queues
19
+ when "dlq"
20
+ DLQ.start(args[1..] || [])
19
21
  when "mcp"
20
22
  start_mcp_server
23
+ when "doctor"
24
+ run_doctor
21
25
  when "version"
22
26
  puts "pgbus #{Pgbus::VERSION}"
23
27
  when "help", "--help", "-h"
@@ -107,7 +111,7 @@ module Pgbus
107
111
  def apply_capsule_filter(name)
108
112
  capsule = Pgbus.configuration.capsule_named(name)
109
113
  unless capsule
110
- available = (Pgbus.configuration.workers || []).filter_map { |c| c[:name] || c["name"] }
114
+ available = (Pgbus.configuration.workers || []).filter_map { |c| c[:name] }
111
115
  raise ArgumentError,
112
116
  "no capsule named #{name.inspect} (available: #{available.join(", ")})"
113
117
  end
@@ -141,6 +145,15 @@ module Pgbus
141
145
  Pgbus::MCP::Runner.run
142
146
  end
143
147
 
148
+ # Runs the deployment preflight diagnostics and prints the report. Exits 1
149
+ # on any failed check so it can gate a deploy or CI run; exits 0 when all
150
+ # checks pass (warnings do not fail). Doctor itself never raises.
151
+ def run_doctor
152
+ doctor = Pgbus::Doctor.new
153
+ puts doctor.report
154
+ exit 1 unless doctor.success?
155
+ end
156
+
144
157
  def list_queues
145
158
  Pgbus.client.list_queues
146
159
  metrics = Pgbus.client.metrics
@@ -163,7 +176,11 @@ module Pgbus
163
176
  start Start the Pgbus supervisor (workers + dispatcher)
164
177
  status Show running Pgbus processes
165
178
  queues List queues with metrics
179
+ dlq Inspect and drain dead-letter queues
180
+ (list/show/retry/retry-all/purge)
166
181
  mcp Start the read-only MCP diagnostic server over stdio
182
+ doctor Run environment diagnostics (config, DB, PGMQ, queues,
183
+ LISTEN/NOTIFY, process liveness); exits 1 on any failure
167
184
  version Show version
168
185
  help Show this help
169
186
 
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Client
5
+ # In-memory, process-local circuit breaker for database-down conditions.
6
+ #
7
+ # Distinct from Pgbus::CircuitBreaker (which is per-queue and persists its
8
+ # pause state in the database — useless when the database itself is down).
9
+ # This latch is a single shared object owned by Pgbus::Client that trips on
10
+ # raw connection failures across *any* operation, so a fleet of workers
11
+ # stops hammering a dead database and flooding the error tracker for the
12
+ # whole outage (issue #197).
13
+ #
14
+ # State machine:
15
+ # closed -> open after OPEN_THRESHOLD consecutive ConnectionErrors
16
+ # open -> half_open once the backoff window elapses (admits ONE probe)
17
+ # half_open -> closed on a successful probe (resets backoff)
18
+ # half_open -> open on a failed probe (backoff doubles, capped)
19
+ #
20
+ # Thresholds are constants, not configuration, mirroring CircuitBreaker:
21
+ # the values rarely need tuning and exposing them never proved useful.
22
+ #
23
+ # Thread safety: a single Mutex serializes every state transition. The
24
+ # guarded operation itself runs *outside* the lock (only the gate decision
25
+ # and the outcome recording are inside), so a slow probe never blocks other
26
+ # worker threads from failing fast.
27
+ class ConnectionHealth
28
+ # Consecutive ConnectionErrors that trip the breaker from closed to open.
29
+ OPEN_THRESHOLD = 5
30
+
31
+ # Initial backoff (seconds) on the first trip. Doubles on each re-open.
32
+ BASE_BACKOFF = 1.0
33
+
34
+ # Cap on the exponential backoff (seconds). After ~6 re-opens the curve
35
+ # plateaus here so a perpetually-down database stops probing more than
36
+ # once a minute.
37
+ MAX_BACKOFF = 60.0
38
+
39
+ def initialize(clock: -> { ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) },
40
+ on_open: nil, on_close: nil)
41
+ @clock = clock
42
+ @on_open = on_open
43
+ @on_close = on_close
44
+ @mutex = Mutex.new
45
+ @state = :closed
46
+ @failure_count = 0
47
+ @trip_count = 0
48
+ @open_until = nil
49
+ end
50
+
51
+ # Gate an operation through the breaker.
52
+ #
53
+ # - closed / half-open-probe-admitted: yields, records the outcome.
54
+ # - open (window not elapsed) or half-open (probe already in flight):
55
+ # raises Pgbus::ConnectionCircuitOpenError without yielding — no pool
56
+ # checkout, no ConnectionError, no ErrorReporter noise.
57
+ #
58
+ # The breaker never swallows an error — every exception propagates. A
59
+ # ConnectionError in the closed state counts toward the trip threshold; a
60
+ # failure of the single half-open probe (on ANY error) re-opens the
61
+ # breaker; any other closed-state error is not counted.
62
+ def run_guarded
63
+ admitted = admit_or_raise # :run (was closed) or :probe (was open)
64
+
65
+ begin
66
+ result = yield
67
+ rescue StandardError => e
68
+ record_failure(admitted, e)
69
+ raise
70
+ end
71
+
72
+ record_success(admitted)
73
+ result
74
+ end
75
+
76
+ def closed?
77
+ @mutex.synchronize { @state == :closed }
78
+ end
79
+
80
+ def open?
81
+ @mutex.synchronize { @state == :open }
82
+ end
83
+
84
+ private
85
+
86
+ # Decide whether this call may proceed. Runs entirely inside the mutex.
87
+ # Returns the admission kind so the outcome recorders can attribute the
88
+ # result to THIS call (the probe vs. an ordinary closed-state read) —
89
+ # critical because the block runs outside the lock, so between admit and
90
+ # record the state may have changed under another thread.
91
+ #
92
+ # When admitting a probe from the open state it flips @state to :half_open
93
+ # so a second racing thread sees :half_open and is rejected — that is the
94
+ # single-probe guarantee.
95
+ def admit_or_raise
96
+ @mutex.synchronize do
97
+ case @state
98
+ when :closed
99
+ :run
100
+ when :open
101
+ raise_open unless window_elapsed?
102
+
103
+ @state = :half_open # admit exactly this thread as the probe
104
+ :probe
105
+ when :half_open
106
+ raise_open # a probe is already in flight
107
+ end
108
+ end
109
+ end
110
+
111
+ def record_success(admitted)
112
+ callback = nil
113
+ @mutex.synchronize do
114
+ if admitted == :probe && @state == :half_open
115
+ # The single probe succeeded — the database is reachable again.
116
+ close!
117
+ callback = @on_close
118
+ elsif @state == :closed
119
+ # An ordinary read completed while closed: clear the failure streak.
120
+ # Do NOT force-close here — a straggler read admitted while closed
121
+ # can return AFTER other threads have tripped the breaker; closing
122
+ # would wipe a freshly-opened breaker (issue #197 straggler race).
123
+ @failure_count = 0
124
+ end
125
+ end
126
+ callback&.call
127
+ end
128
+
129
+ # Any exception ends the operation. A probe failure — whether a
130
+ # ConnectionError or something else (e.g. Pgbus::ReadTimeoutError from a
131
+ # hung socket, which the read paths raise from INSIDE the guard) — must
132
+ # re-open the breaker. If it didn't, a non-ConnectionError probe would
133
+ # leave the latch wedged in :half_open forever, admitting no further
134
+ # probes and never recovering (issue #197 wedge). Ordinary closed-state
135
+ # failures only count toward the threshold when they are ConnectionErrors.
136
+ def record_failure(admitted, error)
137
+ callback_backoff = nil
138
+ @mutex.synchronize do
139
+ if admitted == :probe && @state == :half_open
140
+ callback_backoff = reopen
141
+ elsif @state == :closed && connection_error?(error)
142
+ @failure_count += 1
143
+ callback_backoff = reopen if @failure_count >= OPEN_THRESHOLD
144
+ end
145
+ # Otherwise (a straggler failing after another thread already changed
146
+ # the state) leave the current window untouched.
147
+ end
148
+ @on_open&.call(callback_backoff) if callback_backoff
149
+ end
150
+
151
+ # Transition to closed and fully reset. Must be called with @mutex held.
152
+ def close!
153
+ @state = :closed
154
+ @failure_count = 0
155
+ @trip_count = 0
156
+ @open_until = nil
157
+ end
158
+
159
+ # Transition to open, arm the backoff window, and return the window size
160
+ # so the caller can fire the on_open callback outside the lock. Must be
161
+ # called with @mutex held.
162
+ def reopen
163
+ @trip_count += 1
164
+ backoff = calculate_backoff(@trip_count)
165
+ @state = :open
166
+ @failure_count = 0
167
+ @open_until = clock_now + backoff
168
+ backoff
169
+ end
170
+
171
+ def window_elapsed?
172
+ @open_until.nil? || clock_now >= @open_until
173
+ end
174
+
175
+ def calculate_backoff(trip_count)
176
+ backoff = BASE_BACKOFF * (2**(trip_count - 1))
177
+ [backoff, MAX_BACKOFF].min
178
+ end
179
+
180
+ def raise_open
181
+ raise Pgbus::ConnectionCircuitOpenError,
182
+ "database connection circuit is open (#{OPEN_THRESHOLD}+ consecutive connection failures)"
183
+ end
184
+
185
+ def connection_error?(error)
186
+ defined?(PGMQ::Errors::ConnectionError) && error.is_a?(PGMQ::Errors::ConnectionError)
187
+ end
188
+
189
+ def clock_now
190
+ @clock.call
191
+ end
192
+ end
193
+ end
194
+ end