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,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 = {}