pgbus 0.9.6 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +119 -1
- data/Rakefile +10 -1
- data/app/helpers/pgbus/application_helper.rb +45 -6
- data/app/views/pgbus/jobs/show.html.erb +1 -1
- 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/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 +23 -4
- data/lib/pgbus/configuration.rb +102 -13
- data/lib/pgbus/dedup_cache.rb +8 -0
- data/lib/pgbus/doctor.rb +250 -0
- data/lib/pgbus/engine.rb +15 -0
- data/lib/pgbus/execution_pools/async_pool.rb +7 -0
- data/lib/pgbus/execution_pools/thread_pool.rb +7 -0
- data/lib/pgbus/instrumentation.rb +1 -0
- data/lib/pgbus/integrations/appsignal/probe.rb +23 -1
- 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 +190 -0
- data/lib/pgbus/metrics.rb +42 -0
- data/lib/pgbus/process/consumer.rb +215 -8
- 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 +396 -46
- data/lib/pgbus/process/worker.rb +298 -35
- data/lib/pgbus/recurring/scheduler.rb +15 -1
- data/lib/pgbus/streams/turbo_broadcastable.rb +7 -5
- data/lib/pgbus/table_maintenance.rb +13 -2
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +20 -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 +68 -0
- data/lib/pgbus/web/streamer/instance.rb +55 -1
- data/lib/pgbus/web/streamer/listener.rb +72 -9
- data/lib/pgbus.rb +37 -0
- data/lib/rubocop/cop/pgbus/no_ruby_timeout.rb +42 -0
- data/lib/rubocop/pgbus.rb +5 -0
- data/lib/tasks/pgbus_doctor.rake +12 -0
- metadata +19 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Process
|
|
5
|
+
# One-shot self-probe that verifies a freshly built LISTEN connection can
|
|
6
|
+
# actually receive a NOTIFY it sends to itself. Two real-world failure modes
|
|
7
|
+
# break LISTEN/NOTIFY silently, and both are caught here:
|
|
8
|
+
#
|
|
9
|
+
# 1. Transaction-mode PgBouncer — LISTEN registrations do not survive the
|
|
10
|
+
# transaction boundaries the pooler inserts, so a self-NOTIFY is never
|
|
11
|
+
# delivered (the wait times out).
|
|
12
|
+
# 2. Read-only replica — `pg_notify()` runs inside a read-only transaction
|
|
13
|
+
# and raises, so the probe never even reaches the wait.
|
|
14
|
+
#
|
|
15
|
+
# In both cases the caller "connected successfully" but would never wake on a
|
|
16
|
+
# real INSERT, degrading to slow fallback polling with no explanation. The
|
|
17
|
+
# probe surfaces the condition with an actionable error naming the direct-
|
|
18
|
+
# connection overrides, then returns false so the caller can degrade
|
|
19
|
+
# gracefully (workers keep polling; the streamer starts anyway).
|
|
20
|
+
#
|
|
21
|
+
# These listeners own raw PG::Connections by design — the documented
|
|
22
|
+
# exception to the "all PGMQ access through Pgbus::Client" rule — so the
|
|
23
|
+
# probe operates directly on a PG::Connection.
|
|
24
|
+
module NotifyProbe
|
|
25
|
+
PROBE_TIMEOUT_SECONDS = 2
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Runs the probe on an already-built LISTEN connection. Returns true when
|
|
29
|
+
# the self-NOTIFY is delivered within PROBE_TIMEOUT_SECONDS, false on
|
|
30
|
+
# timeout or when pg_notify raises. Never raises: a probe is diagnostic,
|
|
31
|
+
# so a failure must not take down listener startup.
|
|
32
|
+
def probe_notify_delivery!(conn, logger: Pgbus.logger)
|
|
33
|
+
channel = probe_channel
|
|
34
|
+
begin
|
|
35
|
+
conn.exec(%(LISTEN "#{channel}"))
|
|
36
|
+
conn.exec_params("SELECT pg_notify($1, '')", [channel])
|
|
37
|
+
delivered = wait_for_probe(conn, channel)
|
|
38
|
+
log_failure(logger) unless delivered
|
|
39
|
+
delivered
|
|
40
|
+
rescue PG::Error => e
|
|
41
|
+
# Read-only replica: pg_notify raised inside a read-only transaction.
|
|
42
|
+
log_failure(logger, error: e)
|
|
43
|
+
false
|
|
44
|
+
ensure
|
|
45
|
+
safe_unlisten(conn, channel, logger: logger)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Unique per process AND per invocation so two probes (worker + streamer
|
|
52
|
+
# in the same process, or a retry) never collide on the same channel.
|
|
53
|
+
def probe_channel
|
|
54
|
+
"pgbus_probe_#{::Process.pid}_#{probe_nonce}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def probe_nonce
|
|
58
|
+
require "securerandom"
|
|
59
|
+
SecureRandom.hex(4)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# wait_for_notify yields the NEXT notification on the connection, not one
|
|
63
|
+
# filtered to a channel, so confirm the delivered channel is our probe's.
|
|
64
|
+
# The probe runs on a fresh connection with only this channel LISTENed,
|
|
65
|
+
# but scoping the check here keeps the method correct regardless of the
|
|
66
|
+
# caller's LISTEN ordering — an incidental NOTIFY must not read as a
|
|
67
|
+
# successful probe (that would mask the exact failure this catches).
|
|
68
|
+
def wait_for_probe(conn, channel)
|
|
69
|
+
delivered = false
|
|
70
|
+
conn.wait_for_notify(PROBE_TIMEOUT_SECONDS) do |notified_channel, _pid, _payload|
|
|
71
|
+
delivered = (notified_channel == channel)
|
|
72
|
+
end
|
|
73
|
+
delivered
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def safe_unlisten(conn, channel, logger: Pgbus.logger)
|
|
77
|
+
conn.exec(%(UNLISTEN "#{channel}"))
|
|
78
|
+
rescue PG::Error => e
|
|
79
|
+
logger.debug { "[Pgbus::NotifyProbe] best-effort UNLISTEN \"#{channel}\" failed: #{e.class}: #{e.message}" }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def log_failure(logger, error: nil)
|
|
83
|
+
detail = error ? " (#{error.class}: #{error.message})" : ""
|
|
84
|
+
logger.error do
|
|
85
|
+
"[Pgbus::NotifyProbe] LISTEN/NOTIFY self-probe failed#{detail}: a self-NOTIFY was not delivered " \
|
|
86
|
+
"within #{PROBE_TIMEOUT_SECONDS}s. This connection cannot receive wake-ups — it is likely a " \
|
|
87
|
+
"transaction-mode pooler (e.g. PgBouncer) that drops LISTEN, or a read-only replica. The " \
|
|
88
|
+
"listener will fall back to slow polling. Point the LISTEN connection at a DIRECT database " \
|
|
89
|
+
"port: for workers set worker_notify_database_url (or worker_notify_host / worker_notify_port); " \
|
|
90
|
+
"for the streamer set streams_database_url (or streams_host / streams_port)."
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Process
|
|
5
|
+
# Rejects a freshly built LISTEN connection that landed on a read-only
|
|
6
|
+
# replica. After a PostgreSQL failover, stale DNS can point a fresh
|
|
7
|
+
# `PG.connect` at the demoted master — now a replica. NOTIFY fires only on
|
|
8
|
+
# the primary, so the listener would connect "successfully" and then sit
|
|
9
|
+
# deaf forever, waking on nothing and degrading to slow fallback polling
|
|
10
|
+
# with no explanation.
|
|
11
|
+
#
|
|
12
|
+
# `SELECT pg_is_in_recovery()` returns `t` on a replica (a standby in
|
|
13
|
+
# recovery) and `f` on the primary. On a replica we raise
|
|
14
|
+
# ReplicaConnectionError so the caller's existing reconnect loop closes the
|
|
15
|
+
# connection, backs off, and retries. Each retry calls `PG.connect` fresh,
|
|
16
|
+
# which re-resolves DNS, so the loop converges on the promoted master once
|
|
17
|
+
# DNS catches up.
|
|
18
|
+
#
|
|
19
|
+
# These listeners own raw PG::Connections by design — the documented
|
|
20
|
+
# exception to the "all PGMQ access through Pgbus::Client" rule — so the
|
|
21
|
+
# validator operates directly on a PG::Connection. It is a sibling of
|
|
22
|
+
# NotifyProbe (delivery self-probe); PrimaryValidator is the cheap
|
|
23
|
+
# every-connection recovery check, NotifyProbe the one-shot start-only
|
|
24
|
+
# delivery check.
|
|
25
|
+
module PrimaryValidator
|
|
26
|
+
RECOVERY_QUERY = "SELECT pg_is_in_recovery()"
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# Runs the recovery check. Returns the connection unchanged on a primary
|
|
30
|
+
# so callers can write `validate_primary!(build_connection)`. Raises
|
|
31
|
+
# ReplicaConnectionError on a replica. A PG::Error from exec (dead
|
|
32
|
+
# connection, etc.) propagates unchanged — callers already rescue
|
|
33
|
+
# PG::Error and treat it as a reconnect trigger.
|
|
34
|
+
def validate_primary!(conn)
|
|
35
|
+
return conn unless in_recovery?(conn)
|
|
36
|
+
|
|
37
|
+
raise ReplicaConnectionError,
|
|
38
|
+
"LISTEN connection landed on a read-only replica " \
|
|
39
|
+
"(pg_is_in_recovery() => t). After a failover, stale DNS can " \
|
|
40
|
+
"point a fresh connection at the demoted master. NOTIFY fires " \
|
|
41
|
+
"only on the primary, so this connection would never wake. " \
|
|
42
|
+
"Rejecting it; the reconnect loop will re-resolve DNS and retry."
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def in_recovery?(conn)
|
|
48
|
+
conn.exec(RECOVERY_QUERY).getvalue(0, 0) == "t"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -7,6 +7,12 @@ module Pgbus
|
|
|
7
7
|
base.attr_reader :signal_queue
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
+
# The signals setup_signals has installed handlers for (and will restore on
|
|
11
|
+
# restore_signals). Empty until setup_signals runs.
|
|
12
|
+
def handled_signals
|
|
13
|
+
(@previous_handlers || {}).keys
|
|
14
|
+
end
|
|
15
|
+
|
|
10
16
|
def setup_signals
|
|
11
17
|
@signal_queue = Queue.new
|
|
12
18
|
@previous_handlers = {}
|