pgbus 0.9.7 → 0.9.9
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 +4 -4
- data/CHANGELOG.md +367 -0
- data/README.md +454 -25
- data/Rakefile +10 -1
- data/app/helpers/pgbus/application_helper.rb +37 -0
- data/app/views/pgbus/processes/_processes_table.html.erb +4 -1
- data/config/locales/da.yml +4 -0
- data/config/locales/de.yml +4 -0
- data/config/locales/en.yml +4 -0
- data/config/locales/es.yml +4 -0
- data/config/locales/fi.yml +4 -0
- data/config/locales/fr.yml +4 -0
- data/config/locales/it.yml +4 -0
- data/config/locales/ja.yml +4 -0
- data/config/locales/nb.yml +4 -0
- data/config/locales/nl.yml +4 -0
- data/config/locales/pt.yml +4 -0
- data/config/locales/sv.yml +4 -0
- data/lib/generators/pgbus/{add_job_locks_generator.rb → add_uniqueness_keys_generator.rb} +5 -5
- data/lib/pgbus/active_job/adapter.rb +1 -1
- data/lib/pgbus/active_job/executor.rb +25 -4
- data/lib/pgbus/cli/dlq.rb +164 -0
- data/lib/pgbus/cli.rb +18 -1
- data/lib/pgbus/client/connection_health.rb +194 -0
- data/lib/pgbus/client.rb +592 -73
- data/lib/pgbus/config_loader.rb +50 -4
- data/lib/pgbus/configuration.rb +294 -79
- data/lib/pgbus/dedup_cache.rb +8 -0
- data/lib/pgbus/doctor.rb +275 -0
- data/lib/pgbus/engine.rb +15 -0
- data/lib/pgbus/execution_pools/async_pool.rb +9 -2
- data/lib/pgbus/execution_pools/thread_pool.rb +7 -0
- data/lib/pgbus/generators/config_converter.rb +7 -5
- data/lib/pgbus/generators/migration_detector.rb +20 -16
- data/lib/pgbus/instrumentation.rb +3 -1
- data/lib/pgbus/integrations/appsignal/probe.rb +23 -1
- data/lib/pgbus/integrations/appsignal/subscriber.rb +17 -2
- data/lib/pgbus/metrics/backend.rb +38 -0
- data/lib/pgbus/metrics/backends/prometheus.rb +123 -0
- data/lib/pgbus/metrics/backends/statsd.rb +64 -0
- data/lib/pgbus/metrics/prometheus_exporter.rb +34 -0
- data/lib/pgbus/metrics/subscriber.rb +214 -0
- data/lib/pgbus/metrics.rb +43 -0
- data/lib/pgbus/pgmq_schema/pgmq_v1.11.1.sql +2126 -0
- data/lib/pgbus/pgmq_schema.rb +7 -2
- data/lib/pgbus/process/consumer.rb +354 -18
- data/lib/pgbus/process/consumer_priority.rb +34 -0
- data/lib/pgbus/process/dispatcher.rb +265 -41
- data/lib/pgbus/process/heartbeat.rb +18 -5
- data/lib/pgbus/process/memory_usage.rb +48 -0
- data/lib/pgbus/process/notify_listener.rb +26 -7
- data/lib/pgbus/process/notify_probe.rb +96 -0
- data/lib/pgbus/process/primary_validator.rb +53 -0
- data/lib/pgbus/process/signal_handler.rb +6 -0
- data/lib/pgbus/process/supervisor.rb +423 -50
- data/lib/pgbus/process/worker.rb +288 -35
- data/lib/pgbus/recurring/schedule.rb +1 -2
- data/lib/pgbus/recurring/scheduler.rb +15 -1
- data/lib/pgbus/serializer.rb +4 -4
- data/lib/pgbus/streams/broadcastable_override.rb +0 -8
- data/lib/pgbus/streams/signed_name.rb +2 -2
- data/lib/pgbus/streams/turbo_broadcastable.rb +7 -5
- data/lib/pgbus/table_maintenance.rb +13 -2
- data/lib/pgbus/uniqueness.rb +11 -12
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +36 -4
- data/lib/pgbus/web/health_app.rb +102 -0
- data/lib/pgbus/web/health_server.rb +144 -0
- data/lib/pgbus/web/payload_filter.rb +3 -3
- data/lib/pgbus/web/streamer/instance.rb +58 -2
- data/lib/pgbus/web/streamer/listener.rb +69 -21
- data/lib/pgbus.rb +77 -0
- data/lib/tasks/pgbus_doctor.rake +12 -0
- metadata +19 -4
- data/app/models/pgbus/job_lock.rb +0 -98
- data/lib/generators/pgbus/templates/add_job_locks.rb.erb +0 -21
data/lib/pgbus/dedup_cache.rb
CHANGED
|
@@ -60,11 +60,19 @@ module Pgbus
|
|
|
60
60
|
|
|
61
61
|
def evict(key)
|
|
62
62
|
@cache.delete(key)
|
|
63
|
+
# Concurrent::Array#delete is O(n), but this only runs on expiry and n is
|
|
64
|
+
# bounded by @max_size, so it stays cheap. Without it, @insertion_order
|
|
65
|
+
# leaks and stale duplicates can evict live keys in evict_oldest.
|
|
66
|
+
@insertion_order.delete(key)
|
|
63
67
|
end
|
|
64
68
|
|
|
65
69
|
def evict_oldest
|
|
66
70
|
while @cache.size >= @max_size && !@insertion_order.empty?
|
|
67
71
|
oldest_key = @insertion_order.shift
|
|
72
|
+
# Skip residual order entries for keys already gone from @cache so a
|
|
73
|
+
# stale duplicate never evicts a freshly re-marked (live) key.
|
|
74
|
+
next unless @cache.key?(oldest_key)
|
|
75
|
+
|
|
68
76
|
@cache.delete(oldest_key)
|
|
69
77
|
end
|
|
70
78
|
end
|
data/lib/pgbus/doctor.rb
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "pgbus/mcp/health_analyzer"
|
|
5
|
+
|
|
6
|
+
module Pgbus
|
|
7
|
+
# Preflight diagnostics for a pgbus deployment — the single command that
|
|
8
|
+
# answers "is this environment healthy enough to run?". Runs seven checks and
|
|
9
|
+
# returns a machine-readable result plus a human report, so `pgbus doctor`
|
|
10
|
+
# and `rake pgbus:doctor` can gate a deploy or CI run (exit 0 on success,
|
|
11
|
+
# 1 on any failure).
|
|
12
|
+
#
|
|
13
|
+
# Doctor never touches PGMQ or PostgreSQL directly: every probe goes through
|
|
14
|
+
# Pgbus::Client (DB, PGMQ schema, queues, NOTIFY) or Pgbus::Web::DataSource
|
|
15
|
+
# (process liveness, via Pgbus::MCP::HealthAnalyzer). It also never raises —
|
|
16
|
+
# a broken environment turns into :fail results, never a crash — so it is
|
|
17
|
+
# safe to run against a database that is down or a half-installed schema.
|
|
18
|
+
class Doctor
|
|
19
|
+
# A single check result. status is one of :ok, :warn, :fail.
|
|
20
|
+
Check = Struct.new(:name, :status, :detail) do
|
|
21
|
+
def to_h
|
|
22
|
+
{ name: name, status: status, detail: detail }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
STATUS_ICON = { ok: "✓", warn: "!", fail: "✗" }.freeze
|
|
27
|
+
|
|
28
|
+
def initialize(config: Pgbus.configuration, client: Pgbus.client, data_source: nil)
|
|
29
|
+
@config = config
|
|
30
|
+
@client = client
|
|
31
|
+
@data_source = data_source || Pgbus::Web::DataSource.new(client: client)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Run all checks and return an array of result hashes:
|
|
35
|
+
# { name:, status: :ok|:warn|:fail, detail: }
|
|
36
|
+
def run
|
|
37
|
+
@run ||= [
|
|
38
|
+
check_configuration,
|
|
39
|
+
check_database,
|
|
40
|
+
check_pgmq_schema,
|
|
41
|
+
check_queues,
|
|
42
|
+
check_notify,
|
|
43
|
+
check_processes,
|
|
44
|
+
check_allowed_global_id_models
|
|
45
|
+
].map(&:to_h)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# True when no check failed. Warnings do not fail the run — they surface a
|
|
49
|
+
# concern (e.g. an outdated PGMQ schema) without blocking a deploy.
|
|
50
|
+
def success?
|
|
51
|
+
run.none? { |c| c[:status] == :fail }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Human-readable report: one line per check, then a resolved-config summary
|
|
55
|
+
# with passwords redacted. Suitable for stdout in the CLI and rake task.
|
|
56
|
+
def report
|
|
57
|
+
lines = ["Pgbus Doctor", "=" * 40]
|
|
58
|
+
run.each do |check|
|
|
59
|
+
icon = STATUS_ICON.fetch(check[:status], "?")
|
|
60
|
+
lines << format("%<icon>s %<name>-22s %<detail>s", icon: icon, name: check[:name], detail: check[:detail])
|
|
61
|
+
end
|
|
62
|
+
lines << ""
|
|
63
|
+
lines << "Configuration"
|
|
64
|
+
lines << ("-" * 40)
|
|
65
|
+
config_summary.each { |key, value| lines << format(" %<key>-20s %<value>s", key: key, value: value) }
|
|
66
|
+
lines.join("\n")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Resolved-config summary for the report. Password-bearing fields
|
|
70
|
+
# (database_url, connection_params) are redacted.
|
|
71
|
+
def config_summary
|
|
72
|
+
{
|
|
73
|
+
queue_prefix: @config.queue_prefix,
|
|
74
|
+
default_queue: @config.default_queue,
|
|
75
|
+
pgmq_schema_mode: @config.pgmq_schema_mode,
|
|
76
|
+
resolved_pool_size: safe { @config.resolved_pool_size },
|
|
77
|
+
listen_notify: @config.listen_notify,
|
|
78
|
+
roles: @config.roles || "all",
|
|
79
|
+
capsules: capsule_summary,
|
|
80
|
+
database_url: redacted_database_url,
|
|
81
|
+
connection_params: redacted_connection_params
|
|
82
|
+
}.compact
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# True in a Rails production environment. Guarded so the doctor still runs
|
|
88
|
+
# outside Rails (plain Ruby, tests) without assuming Rails is loaded.
|
|
89
|
+
def production?
|
|
90
|
+
defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# 1. Configuration validity.
|
|
94
|
+
def check_configuration
|
|
95
|
+
@config.validate!
|
|
96
|
+
Check.new(name: "Configuration", status: :ok, detail: "valid")
|
|
97
|
+
rescue Pgbus::ConfigurationError, ArgumentError => e
|
|
98
|
+
Check.new(name: "Configuration", status: :fail, detail: e.message)
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
Check.new(name: "Configuration", status: :fail, detail: "#{e.class}: #{e.message}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# 2. Database connectivity — SELECT 1 via Client#ping.
|
|
104
|
+
def check_database
|
|
105
|
+
@client.ping
|
|
106
|
+
Check.new(name: "Database", status: :ok, detail: "reachable")
|
|
107
|
+
rescue StandardError => e
|
|
108
|
+
Check.new(name: "Database", status: :fail, detail: "#{e.class}: #{e.message}")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# 3. PGMQ schema presence + installed-vs-vendored version.
|
|
112
|
+
#
|
|
113
|
+
# A nil version does NOT automatically mean "not installed": PGMQ installed
|
|
114
|
+
# via the extension, or before pgbus added version tracking, leaves the
|
|
115
|
+
# pgmq schema fully working with no row in pgbus_pgmq_schema_versions. So we
|
|
116
|
+
# distinguish, mirroring `pgbus:pgmq:status`: schema absent → fail; schema
|
|
117
|
+
# present but untracked → warn; tracked but behind → warn; current → ok.
|
|
118
|
+
def check_pgmq_schema
|
|
119
|
+
installed = @client.pgmq_schema_version
|
|
120
|
+
latest = Pgbus::PgmqSchema.latest_version
|
|
121
|
+
|
|
122
|
+
if installed.nil?
|
|
123
|
+
unless @client.pgmq_installed?
|
|
124
|
+
return Check.new(name: "PGMQ schema", status: :fail,
|
|
125
|
+
detail: "not installed — run `rails generate pgbus:install`")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
return Check.new(name: "PGMQ schema", status: :warn,
|
|
129
|
+
detail: "installed but no version tracking — run `rails generate pgbus:upgrade_pgmq`")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
if Gem::Version.new(installed) < Gem::Version.new(latest)
|
|
133
|
+
Check.new(name: "PGMQ schema", status: :warn,
|
|
134
|
+
detail: "installed #{installed}, vendored #{latest} — run `rails generate pgbus:upgrade_pgmq`")
|
|
135
|
+
else
|
|
136
|
+
Check.new(name: "PGMQ schema", status: :ok, detail: "up to date (#{installed})")
|
|
137
|
+
end
|
|
138
|
+
rescue StandardError => e
|
|
139
|
+
Check.new(name: "PGMQ schema", status: :fail, detail: "#{e.class}: #{e.message}")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# 4. Queue existence — every configured queue must have its PGMQ table(s).
|
|
143
|
+
# Resolve each logical queue to the PHYSICAL names bootstrap creates (via the
|
|
144
|
+
# client's queue strategy) so a priority queue's _p0.._pN sub-tables are what
|
|
145
|
+
# we diff against list_queues — not the bare prefixed name priority mode
|
|
146
|
+
# never creates.
|
|
147
|
+
def check_queues
|
|
148
|
+
configured = @client.configured_queues.flat_map { |q| @client.physical_queue_names(q) }
|
|
149
|
+
existing = existing_queue_names
|
|
150
|
+
missing = configured - existing
|
|
151
|
+
|
|
152
|
+
if missing.empty?
|
|
153
|
+
Check.new(name: "Queues", status: :ok, detail: "#{configured.size} configured queue(s) present")
|
|
154
|
+
else
|
|
155
|
+
Check.new(name: "Queues", status: :fail, detail: "missing queue(s): #{missing.join(", ")}")
|
|
156
|
+
end
|
|
157
|
+
rescue StandardError => e
|
|
158
|
+
Check.new(name: "Queues", status: :fail, detail: "#{e.class}: #{e.message}")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# 5. LISTEN/NOTIFY — when configured on, every configured queue should carry
|
|
162
|
+
# the insert-NOTIFY trigger. A missing trigger is a warning, not a failure:
|
|
163
|
+
# pgbus still works (it falls back to polling), it just loses instant wakeup.
|
|
164
|
+
def check_notify
|
|
165
|
+
return Check.new(name: "LISTEN/NOTIFY", status: :ok, detail: "disabled in config") unless @config.listen_notify
|
|
166
|
+
|
|
167
|
+
without_trigger = @client.configured_queues.reject { |q| @client.notify_enabled?(q) }
|
|
168
|
+
if without_trigger.empty?
|
|
169
|
+
Check.new(name: "LISTEN/NOTIFY", status: :ok, detail: "triggers live on all configured queues")
|
|
170
|
+
else
|
|
171
|
+
Check.new(name: "LISTEN/NOTIFY", status: :warn,
|
|
172
|
+
detail: "no insert trigger on: #{without_trigger.join(", ")} (falling back to polling)")
|
|
173
|
+
end
|
|
174
|
+
rescue StandardError => e
|
|
175
|
+
Check.new(name: "LISTEN/NOTIFY", status: :fail, detail: "#{e.class}: #{e.message}")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# 6. Process liveness — the top-level health verdict (OK/DEGRADED/STALLED)
|
|
179
|
+
# from the shared HealthAnalyzer. STALLED (the silent-worker-wedge) is a
|
|
180
|
+
# failure; DEGRADED is a warning; OK passes.
|
|
181
|
+
def check_processes
|
|
182
|
+
verdict = Pgbus::MCP::HealthAnalyzer.new(@data_source).verdict
|
|
183
|
+
status = verdict[:status]
|
|
184
|
+
detail = Array(verdict[:reasons]).first || status
|
|
185
|
+
|
|
186
|
+
case status
|
|
187
|
+
when "STALLED" then Check.new(name: "Process liveness", status: :fail, detail: detail)
|
|
188
|
+
when "DEGRADED" then Check.new(name: "Process liveness", status: :warn, detail: detail)
|
|
189
|
+
else Check.new(name: "Process liveness", status: :ok, detail: "OK")
|
|
190
|
+
end
|
|
191
|
+
rescue StandardError => e
|
|
192
|
+
Check.new(name: "Process liveness", status: :fail, detail: "#{e.class}: #{e.message}")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# 7. GlobalID allowlist — security. allowed_global_id_models = nil means
|
|
196
|
+
# "allow ANY model as a GlobalID event/job argument", which lets a crafted
|
|
197
|
+
# payload deserialize arbitrary AR models. It's the default for upgrade
|
|
198
|
+
# continuity, so this is a warning (never a failure), and only in production
|
|
199
|
+
# where the blast radius is real.
|
|
200
|
+
def check_allowed_global_id_models
|
|
201
|
+
if @config.allowed_global_id_models.nil? && production?
|
|
202
|
+
return Check.new(name: "GlobalID allowlist", status: :warn,
|
|
203
|
+
detail: "allowed_global_id_models is nil (allow-all) in production — " \
|
|
204
|
+
"set an explicit allowlist of models permitted as GlobalID arguments")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
Check.new(name: "GlobalID allowlist", status: :ok,
|
|
208
|
+
detail: @config.allowed_global_id_models.nil? ? "allow-all (non-production)" : "allowlist configured")
|
|
209
|
+
rescue StandardError => e
|
|
210
|
+
Check.new(name: "GlobalID allowlist", status: :fail, detail: "#{e.class}: #{e.message}")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Physical queue names known to PGMQ. list_queues rows may be Hashes (with a
|
|
214
|
+
# :queue_name key) or value objects responding to #queue_name.
|
|
215
|
+
def existing_queue_names
|
|
216
|
+
Array(@client.list_queues).map do |row|
|
|
217
|
+
if row.respond_to?(:queue_name)
|
|
218
|
+
row.queue_name
|
|
219
|
+
elsif row.is_a?(Hash)
|
|
220
|
+
row[:queue_name] || row["queue_name"]
|
|
221
|
+
end
|
|
222
|
+
end.compact
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def capsule_summary
|
|
226
|
+
Array(@config.workers).map { |w| w[:name] || (w[:queues] || []).join("+") }.join(", ")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Redact the password from a libpq URL/URI while keeping host/db visible so
|
|
230
|
+
# the operator can still confirm which database they pointed at.
|
|
231
|
+
def redacted_database_url
|
|
232
|
+
return nil unless @config.database_url
|
|
233
|
+
|
|
234
|
+
redact_url(@config.database_url)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# connection_params is a free-form libpq keyword hash, so redact every key
|
|
238
|
+
# whose name looks secret (password, sslpassword, ...) — not just :password —
|
|
239
|
+
# rather than assuming a fixed shape. Preserves symbol/string key form.
|
|
240
|
+
SECRET_KEY_PATTERN = /pass|secret|token/i
|
|
241
|
+
private_constant :SECRET_KEY_PATTERN
|
|
242
|
+
|
|
243
|
+
def redacted_connection_params
|
|
244
|
+
params = @config.connection_params
|
|
245
|
+
return nil unless params.is_a?(Hash)
|
|
246
|
+
|
|
247
|
+
params.each_with_object({}) do |(key, value), out|
|
|
248
|
+
out[key] = key.to_s.match?(SECRET_KEY_PATTERN) ? "[REDACTED]" : value
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Replace the password in a userinfo authority (scheme://user:pass@host) or a
|
|
253
|
+
# key=value conninfo string (password=secret). Falls back to a blanket
|
|
254
|
+
# redaction label if parsing fails, never leaking the original.
|
|
255
|
+
#
|
|
256
|
+
# The userinfo password can itself contain '@' and ':' (common in generated
|
|
257
|
+
# secrets), so match it greedily up to the LAST '@' before the authority's
|
|
258
|
+
# host — a lazy `[^@]+` would stop at the first '@' and leak the remainder.
|
|
259
|
+
def redact_url(url)
|
|
260
|
+
redacted = url.sub(%r{(://[^:/@]+:).+(@[^@/]*(?:[/?]|\z))}, '\1[REDACTED]\2')
|
|
261
|
+
redacted.gsub(/(\bpassword=)('[^']*'|[^\s'"]+)/i, '\1[REDACTED]')
|
|
262
|
+
rescue StandardError
|
|
263
|
+
"[REDACTED]"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Wrap a value-producing block so a broken resolver (e.g. resolved_pool_size
|
|
267
|
+
# raising on a malformed worker config) degrades to nil instead of raising
|
|
268
|
+
# out of config_summary.
|
|
269
|
+
def safe
|
|
270
|
+
yield
|
|
271
|
+
rescue StandardError
|
|
272
|
+
nil
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
data/lib/pgbus/engine.rb
CHANGED
|
@@ -60,6 +60,7 @@ module Pgbus
|
|
|
60
60
|
load File.expand_path("../tasks/pgbus_pgmq.rake", __dir__)
|
|
61
61
|
load File.expand_path("../tasks/pgbus_streams.rake", __dir__)
|
|
62
62
|
load File.expand_path("../tasks/pgbus_autovacuum.rake", __dir__)
|
|
63
|
+
load File.expand_path("../tasks/pgbus_doctor.rake", __dir__)
|
|
63
64
|
end
|
|
64
65
|
|
|
65
66
|
initializer "pgbus.i18n" do
|
|
@@ -86,6 +87,20 @@ module Pgbus
|
|
|
86
87
|
end
|
|
87
88
|
end
|
|
88
89
|
|
|
90
|
+
# Generic metrics adapter. Opt-in via config.metrics_backend; nil (default)
|
|
91
|
+
# installs no subscription, so there is zero overhead when unused. Runs
|
|
92
|
+
# independently of AppSignal — both consume the same pgbus.* events and both
|
|
93
|
+
# can be active at once.
|
|
94
|
+
initializer "pgbus.metrics", after: :load_config_initializers do
|
|
95
|
+
ActiveSupport.on_load(:after_initialize) do
|
|
96
|
+
next if Pgbus.configuration.metrics_backend.nil?
|
|
97
|
+
|
|
98
|
+
backend = Pgbus::Metrics.resolve_backend(Pgbus.configuration.metrics_backend)
|
|
99
|
+
Pgbus::Metrics::Subscriber.install!(backend: backend)
|
|
100
|
+
Pgbus.logger.info { "[Pgbus] Metrics subscriber installed (#{backend.class})" }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
89
104
|
# Install the watermark cache middleware ahead of the app's own
|
|
90
105
|
# middleware so the thread-local cache is cleared between every
|
|
91
106
|
# Rack request. Without this, repeated page renders served by the
|
|
@@ -25,7 +25,7 @@ module Pgbus
|
|
|
25
25
|
|
|
26
26
|
def post(&block)
|
|
27
27
|
raise_if_fatal!
|
|
28
|
-
raise "Execution pool is shutting down" if shutdown?
|
|
28
|
+
raise Pgbus::ExecutionPoolError, "Execution pool is shutting down" if shutdown?
|
|
29
29
|
|
|
30
30
|
reserved = false
|
|
31
31
|
reserve_capacity!
|
|
@@ -46,6 +46,13 @@ module Pgbus
|
|
|
46
46
|
available_capacity.positive?
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
# True only when every slot is free — all in-flight work has finished.
|
|
50
|
+
# idle? answers "can this pool accept work?"; quiesced? answers
|
|
51
|
+
# "has this pool drained?". The worker drain loop needs the latter.
|
|
52
|
+
def quiesced?
|
|
53
|
+
available_capacity >= @capacity
|
|
54
|
+
end
|
|
55
|
+
|
|
49
56
|
def shutdown
|
|
50
57
|
@state_mutex.synchronize do
|
|
51
58
|
return false if @shutdown_flag
|
|
@@ -186,7 +193,7 @@ module Pgbus
|
|
|
186
193
|
|
|
187
194
|
def reserve_capacity!
|
|
188
195
|
@mutex.synchronize do
|
|
189
|
-
raise "Execution pool is at capacity" if @available_capacity <= 0
|
|
196
|
+
raise Pgbus::ExecutionPoolError, "Execution pool is at capacity" if @available_capacity <= 0
|
|
190
197
|
|
|
191
198
|
@available_capacity -= 1
|
|
192
199
|
end
|
|
@@ -37,6 +37,13 @@ module Pgbus
|
|
|
37
37
|
available_capacity.positive?
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# True only when every slot is free — all in-flight work has finished.
|
|
41
|
+
# idle? answers "can this pool accept work?"; quiesced? answers
|
|
42
|
+
# "has this pool drained?". The worker drain loop needs the latter.
|
|
43
|
+
def quiesced?
|
|
44
|
+
available_capacity >= @capacity
|
|
45
|
+
end
|
|
46
|
+
|
|
40
47
|
def shutdown
|
|
41
48
|
@pool.shutdown
|
|
42
49
|
end
|
|
@@ -11,7 +11,9 @@ module Pgbus
|
|
|
11
11
|
#
|
|
12
12
|
# Drops settings that:
|
|
13
13
|
# - match the gem default (no point restating it)
|
|
14
|
-
# - are deprecated (
|
|
14
|
+
# - are deprecated (settings that no longer exist in the public API —
|
|
15
|
+
# see DEPRECATED_SETTINGS; pool_size is NOT one of these, it's a
|
|
16
|
+
# live accessor that overrides auto-tuning and is preserved)
|
|
15
17
|
#
|
|
16
18
|
# Converts seconds to durations when they evenly divide into a clean
|
|
17
19
|
# unit (7 days, 30 days, 10 minutes). Falls back to the raw integer
|
|
@@ -28,7 +30,7 @@ module Pgbus
|
|
|
28
30
|
# generator's CLI wrapper writes the new initializer and tells the
|
|
29
31
|
# user to delete the YAML when ready.
|
|
30
32
|
class ConfigConverter
|
|
31
|
-
class Error <
|
|
33
|
+
class Error < Pgbus::Error; end
|
|
32
34
|
|
|
33
35
|
# Setters that accept ActiveSupport::Duration (PR 5).
|
|
34
36
|
DURATION_SETTINGS = %w[
|
|
@@ -38,15 +40,15 @@ module Pgbus
|
|
|
38
40
|
|
|
39
41
|
# Settings that no longer exist in the public API. The converter
|
|
40
42
|
# silently drops these from the generated initializer so users on
|
|
41
|
-
# legacy YAML get a clean migration.
|
|
43
|
+
# legacy YAML get a clean migration. NOTE: pool_size is intentionally
|
|
44
|
+
# NOT here — it's a live Configuration#pool_size accessor users set to
|
|
45
|
+
# override auto-tuning, so it must survive the conversion unchanged.
|
|
42
46
|
#
|
|
43
|
-
# - pool_size -> auto-tuned from worker thread counts
|
|
44
47
|
# - notify_throttle_ms -> Pgbus::Client::NOTIFY_THROTTLE_MS
|
|
45
48
|
# - circuit_breaker_* -> Pgbus::CircuitBreaker constants
|
|
46
49
|
# - archive_compaction_* -> Pgbus::Process::Dispatcher constants
|
|
47
50
|
# - dead_letter_queue_suffix -> Pgbus::DEAD_LETTER_SUFFIX (frozen)
|
|
48
51
|
DEPRECATED_SETTINGS = %w[
|
|
49
|
-
pool_size
|
|
50
52
|
notify_throttle_ms
|
|
51
53
|
circuit_breaker_threshold circuit_breaker_base_backoff circuit_breaker_max_backoff
|
|
52
54
|
archive_compaction_interval archive_compaction_batch_size
|
|
@@ -35,9 +35,10 @@ module Pgbus
|
|
|
35
35
|
#
|
|
36
36
|
# 5. Indexes missing on existing tables → queued. Additive, safe.
|
|
37
37
|
#
|
|
38
|
-
# 6. Modern
|
|
39
|
-
#
|
|
40
|
-
#
|
|
38
|
+
# 6. Modern uniqueness table (pgbus_uniqueness_keys) missing → queue
|
|
39
|
+
# add_uniqueness_keys unconditionally. A legacy pgbus_job_locks
|
|
40
|
+
# table left over from an older install is not dropped by this
|
|
41
|
+
# detector — run `pgbus:migrate_job_locks` by hand to retire it.
|
|
41
42
|
class MigrationDetector
|
|
42
43
|
# Sentinel returned when the database looks empty of pgbus tables.
|
|
43
44
|
# The caller (pgbus:update generator) should redirect the user to
|
|
@@ -58,13 +59,14 @@ module Pgbus
|
|
|
58
59
|
|
|
59
60
|
# generator_key → Rails generator name. Passed to Thor's invoke.
|
|
60
61
|
#
|
|
61
|
-
# Note: uniqueness_keys
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
62
|
+
# Note: uniqueness_keys invokes add_uniqueness_keys, which only
|
|
63
|
+
# creates the modern pgbus_uniqueness_keys table. If the legacy
|
|
64
|
+
# pgbus_job_locks table is also present (an install mid-migration),
|
|
65
|
+
# run `pgbus:migrate_job_locks` separately to drop it — that
|
|
66
|
+
# generator is not part of this detector's default flow since it
|
|
67
|
+
# requires the legacy table to already exist.
|
|
66
68
|
GENERATOR_MAP = {
|
|
67
|
-
uniqueness_keys: "pgbus:
|
|
69
|
+
uniqueness_keys: "pgbus:add_uniqueness_keys",
|
|
68
70
|
add_job_stats: "pgbus:add_job_stats",
|
|
69
71
|
add_job_stats_latency: "pgbus:add_job_stats_latency",
|
|
70
72
|
add_job_stats_queue_index: "pgbus:add_job_stats_queue_index",
|
|
@@ -81,7 +83,7 @@ module Pgbus
|
|
|
81
83
|
# Human-friendly description of each migration for the generator
|
|
82
84
|
# output. Keeps the update generator's run log readable.
|
|
83
85
|
DESCRIPTIONS = {
|
|
84
|
-
uniqueness_keys: "uniqueness keys table (job deduplication
|
|
86
|
+
uniqueness_keys: "uniqueness keys table (job deduplication)",
|
|
85
87
|
add_job_stats: "job stats table (Insights dashboard)",
|
|
86
88
|
add_job_stats_latency: "job stats latency columns (enqueue_latency_ms, retry_count)",
|
|
87
89
|
add_job_stats_queue_index: "job stats (queue_name, created_at) index",
|
|
@@ -128,10 +130,9 @@ module Pgbus
|
|
|
128
130
|
CORE_INSTALL_TABLES.none? { |t| table_exists?(t) }
|
|
129
131
|
end
|
|
130
132
|
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
# One symbol covers both cases; see GENERATOR_MAP.
|
|
133
|
+
# Queue add_uniqueness_keys whenever the modern table is missing.
|
|
134
|
+
# This does not touch a legacy pgbus_job_locks table — that is
|
|
135
|
+
# handled separately by `pgbus:migrate_job_locks`, see GENERATOR_MAP.
|
|
135
136
|
def uniqueness_key_migrations
|
|
136
137
|
return [] if table_exists?("pgbus_uniqueness_keys")
|
|
137
138
|
|
|
@@ -180,8 +181,11 @@ module Pgbus
|
|
|
180
181
|
def recurring_migrations
|
|
181
182
|
# pgbus_recurring_tasks + pgbus_recurring_executions are the two
|
|
182
183
|
# tables the recurring generator creates. If BOTH exist, nothing
|
|
183
|
-
# to do. If either is missing, we
|
|
184
|
-
#
|
|
184
|
+
# to do. If either is missing, we queue the generator. The
|
|
185
|
+
# add_recurring template uses plain create_table (no if_not_exists),
|
|
186
|
+
# so idempotency is provided by this detector, not the template:
|
|
187
|
+
# the migration is only queued while a table is absent, and never
|
|
188
|
+
# re-invoked once both tables are present.
|
|
185
189
|
return [] if table_exists?("pgbus_recurring_tasks") && table_exists?("pgbus_recurring_executions")
|
|
186
190
|
|
|
187
191
|
[:add_recurring]
|
|
@@ -11,6 +11,7 @@ module Pgbus
|
|
|
11
11
|
# pgbus.client.send_batch — batch enqueue
|
|
12
12
|
# pgbus.client.read_batch — batch dequeue
|
|
13
13
|
# pgbus.client.read_message — single message dequeue
|
|
14
|
+
# pgbus.client.pool — connection pool snapshot (size/available/pool_timeout); emitted per worker heartbeat
|
|
14
15
|
# pgbus.executor.execute — full job execution (deserialize + perform + archive)
|
|
15
16
|
# pgbus.job_completed — job archived successfully
|
|
16
17
|
# pgbus.job_failed — job raised; carries :exception_object
|
|
@@ -20,7 +21,8 @@ module Pgbus
|
|
|
20
21
|
# pgbus.stream.broadcast — stream broadcast (sync or deferred)
|
|
21
22
|
# pgbus.outbox.publish — outbox row created
|
|
22
23
|
# pgbus.recurring.enqueue — scheduler enqueued a due recurring task
|
|
23
|
-
# pgbus.worker.recycle — worker hit a recycle threshold
|
|
24
|
+
# pgbus.worker.recycle — worker hit a recycle threshold (reason/jobs_processed/memory_mb/lifetime_seconds)
|
|
25
|
+
# pgbus.consumer.recycle — consumer hit a recycle threshold (same payload shape as pgbus.worker.recycle)
|
|
24
26
|
# pgbus.serializer.serialize — job/event serialization
|
|
25
27
|
# pgbus.serializer.deserialize — job/event deserialization
|
|
26
28
|
#
|
|
@@ -51,8 +51,9 @@ module Pgbus
|
|
|
51
51
|
|
|
52
52
|
# The actual probe object; AppSignal calls #call once per minute.
|
|
53
53
|
class Runner
|
|
54
|
-
def initialize(data_source: nil)
|
|
54
|
+
def initialize(data_source: nil, client: nil)
|
|
55
55
|
@data_source = data_source
|
|
56
|
+
@client = client
|
|
56
57
|
@hostname = Socket.gethostname
|
|
57
58
|
end
|
|
58
59
|
|
|
@@ -63,6 +64,7 @@ module Pgbus
|
|
|
63
64
|
track_processes
|
|
64
65
|
track_summary
|
|
65
66
|
track_streams
|
|
67
|
+
track_pool
|
|
66
68
|
end
|
|
67
69
|
|
|
68
70
|
private
|
|
@@ -72,6 +74,10 @@ module Pgbus
|
|
|
72
74
|
(::Pgbus::Web::DataSource.new if defined?(::Pgbus::Web::DataSource))
|
|
73
75
|
end
|
|
74
76
|
|
|
77
|
+
def client
|
|
78
|
+
@client ||= (::Pgbus.client if defined?(::Pgbus) && ::Pgbus.respond_to?(:client))
|
|
79
|
+
end
|
|
80
|
+
|
|
75
81
|
def track_queues
|
|
76
82
|
data_source.queues_with_metrics.each do |q|
|
|
77
83
|
tags = { queue: q[:name] }
|
|
@@ -110,6 +116,22 @@ module Pgbus
|
|
|
110
116
|
log_failure("summary metrics", e)
|
|
111
117
|
end
|
|
112
118
|
|
|
119
|
+
# The PGMQ connection pool is per-process (each host owns its own
|
|
120
|
+
# pool), so — unlike the cluster-wide queue/summary gauges — these
|
|
121
|
+
# are tagged with the hostname, same policy as active_processes.
|
|
122
|
+
# pool_stats already rescues to {} internally; the outer rescue here
|
|
123
|
+
# keeps a probe iteration alive if the client itself is unavailable.
|
|
124
|
+
def track_pool
|
|
125
|
+
stats = client&.pool_stats || {}
|
|
126
|
+
return if stats.empty?
|
|
127
|
+
|
|
128
|
+
tags = { hostname: @hostname }
|
|
129
|
+
gauge "pool_size", stats[:size], tags
|
|
130
|
+
gauge "pool_available", stats[:available], tags
|
|
131
|
+
rescue StandardError => e
|
|
132
|
+
log_failure("pool metrics", e)
|
|
133
|
+
end
|
|
134
|
+
|
|
113
135
|
def track_streams
|
|
114
136
|
return unless data_source.respond_to?(:stream_stats_available?) &&
|
|
115
137
|
data_source.stream_stats_available?
|
|
@@ -41,7 +41,13 @@ module Pgbus
|
|
|
41
41
|
subscribe("pgbus.stream.broadcast") { |event| on_stream_broadcast(event) },
|
|
42
42
|
subscribe("pgbus.outbox.publish") { |event| on_outbox_publish(event) },
|
|
43
43
|
subscribe("pgbus.recurring.enqueue") { |event| on_recurring_enqueue(event) },
|
|
44
|
-
subscribe("pgbus.worker.recycle") { |event| on_worker_recycle(event) }
|
|
44
|
+
subscribe("pgbus.worker.recycle") { |event| on_worker_recycle(event) },
|
|
45
|
+
subscribe("pgbus.consumer.recycle") { |event| on_consumer_recycle(event) }
|
|
46
|
+
# pgbus.client.pool is deliberately NOT subscribed here — the
|
|
47
|
+
# minutely Probe#track_pool already reports pgbus_pool_size /
|
|
48
|
+
# pgbus_pool_available gauges for AppSignal (see probe.rb).
|
|
49
|
+
# Subscribing here too would double-report on every heartbeat
|
|
50
|
+
# and skew the gauges.
|
|
45
51
|
]
|
|
46
52
|
@installed = true
|
|
47
53
|
end
|
|
@@ -250,7 +256,16 @@ module Pgbus
|
|
|
250
256
|
::Appsignal.increment_counter(
|
|
251
257
|
"#{METRIC_PREFIX}worker_recycled",
|
|
252
258
|
1,
|
|
253
|
-
{ reason: payload[:reason] }
|
|
259
|
+
{ reason: payload[:reason], kind: "worker" }
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def on_consumer_recycle(event)
|
|
264
|
+
payload = event.payload
|
|
265
|
+
::Appsignal.increment_counter(
|
|
266
|
+
"#{METRIC_PREFIX}worker_recycled",
|
|
267
|
+
1,
|
|
268
|
+
{ reason: payload[:reason], kind: "consumer" }
|
|
254
269
|
)
|
|
255
270
|
end
|
|
256
271
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Metrics
|
|
5
|
+
# Interface every metrics backend implements. The Subscriber only ever calls
|
|
6
|
+
# these three methods, so a backend that satisfies them plugs in directly as
|
|
7
|
+
# `config.metrics_backend`.
|
|
8
|
+
#
|
|
9
|
+
# increment(name, value = 1, tags = {}) — monotonic counter add
|
|
10
|
+
# gauge(name, value, tags = {}) — point-in-time value
|
|
11
|
+
# histogram(name, value, tags = {}) — timing/size distribution sample
|
|
12
|
+
#
|
|
13
|
+
# `name` is a `pgbus_`-prefixed String; `tags` is a Hash of low-cardinality
|
|
14
|
+
# label => value pairs (no msg_id, no event_id).
|
|
15
|
+
class Backend
|
|
16
|
+
def increment(_name, _value = 1, _tags = {})
|
|
17
|
+
raise NotImplementedError, "#{self.class}#increment"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def gauge(_name, _value, _tags = {})
|
|
21
|
+
raise NotImplementedError, "#{self.class}#gauge"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def histogram(_name, _value, _tags = {})
|
|
25
|
+
raise NotImplementedError, "#{self.class}#histogram"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# No-op backend. Used as the resolved backend for a nil configuration so
|
|
29
|
+
# callers never have to nil-check, though in practice the Subscriber is
|
|
30
|
+
# simply not installed when metrics_backend is nil.
|
|
31
|
+
class Null < Backend
|
|
32
|
+
def increment(_name, _value = 1, _tags = {}) = nil
|
|
33
|
+
def gauge(_name, _value, _tags = {}) = nil
|
|
34
|
+
def histogram(_name, _value, _tags = {}) = nil
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|