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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +367 -0
  3. data/README.md +454 -25
  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/generators/pgbus/{add_job_locks_generator.rb → add_uniqueness_keys_generator.rb} +5 -5
  20. data/lib/pgbus/active_job/adapter.rb +1 -1
  21. data/lib/pgbus/active_job/executor.rb +25 -4
  22. data/lib/pgbus/cli/dlq.rb +164 -0
  23. data/lib/pgbus/cli.rb +18 -1
  24. data/lib/pgbus/client/connection_health.rb +194 -0
  25. data/lib/pgbus/client.rb +592 -73
  26. data/lib/pgbus/config_loader.rb +50 -4
  27. data/lib/pgbus/configuration.rb +294 -79
  28. data/lib/pgbus/dedup_cache.rb +8 -0
  29. data/lib/pgbus/doctor.rb +275 -0
  30. data/lib/pgbus/engine.rb +15 -0
  31. data/lib/pgbus/execution_pools/async_pool.rb +9 -2
  32. data/lib/pgbus/execution_pools/thread_pool.rb +7 -0
  33. data/lib/pgbus/generators/config_converter.rb +7 -5
  34. data/lib/pgbus/generators/migration_detector.rb +20 -16
  35. data/lib/pgbus/instrumentation.rb +3 -1
  36. data/lib/pgbus/integrations/appsignal/probe.rb +23 -1
  37. data/lib/pgbus/integrations/appsignal/subscriber.rb +17 -2
  38. data/lib/pgbus/metrics/backend.rb +38 -0
  39. data/lib/pgbus/metrics/backends/prometheus.rb +123 -0
  40. data/lib/pgbus/metrics/backends/statsd.rb +64 -0
  41. data/lib/pgbus/metrics/prometheus_exporter.rb +34 -0
  42. data/lib/pgbus/metrics/subscriber.rb +214 -0
  43. data/lib/pgbus/metrics.rb +43 -0
  44. data/lib/pgbus/pgmq_schema/pgmq_v1.11.1.sql +2126 -0
  45. data/lib/pgbus/pgmq_schema.rb +7 -2
  46. data/lib/pgbus/process/consumer.rb +354 -18
  47. data/lib/pgbus/process/consumer_priority.rb +34 -0
  48. data/lib/pgbus/process/dispatcher.rb +265 -41
  49. data/lib/pgbus/process/heartbeat.rb +18 -5
  50. data/lib/pgbus/process/memory_usage.rb +48 -0
  51. data/lib/pgbus/process/notify_listener.rb +26 -7
  52. data/lib/pgbus/process/notify_probe.rb +96 -0
  53. data/lib/pgbus/process/primary_validator.rb +53 -0
  54. data/lib/pgbus/process/signal_handler.rb +6 -0
  55. data/lib/pgbus/process/supervisor.rb +423 -50
  56. data/lib/pgbus/process/worker.rb +288 -35
  57. data/lib/pgbus/recurring/schedule.rb +1 -2
  58. data/lib/pgbus/recurring/scheduler.rb +15 -1
  59. data/lib/pgbus/serializer.rb +4 -4
  60. data/lib/pgbus/streams/broadcastable_override.rb +0 -8
  61. data/lib/pgbus/streams/signed_name.rb +2 -2
  62. data/lib/pgbus/streams/turbo_broadcastable.rb +7 -5
  63. data/lib/pgbus/table_maintenance.rb +13 -2
  64. data/lib/pgbus/uniqueness.rb +11 -12
  65. data/lib/pgbus/version.rb +1 -1
  66. data/lib/pgbus/web/data_source.rb +36 -4
  67. data/lib/pgbus/web/health_app.rb +102 -0
  68. data/lib/pgbus/web/health_server.rb +144 -0
  69. data/lib/pgbus/web/payload_filter.rb +3 -3
  70. data/lib/pgbus/web/streamer/instance.rb +58 -2
  71. data/lib/pgbus/web/streamer/listener.rb +69 -21
  72. data/lib/pgbus.rb +77 -0
  73. data/lib/tasks/pgbus_doctor.rake +12 -0
  74. metadata +19 -4
  75. data/app/models/pgbus/job_lock.rb +0 -98
  76. data/lib/generators/pgbus/templates/add_job_locks.rb.erb +0 -21
@@ -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
@@ -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 (e.g. pool_size, which is now auto-tuned)
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 < StandardError; end
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 replacements for legacy tables (e.g. pgbus_job_locks
39
- # pgbus_uniqueness_keys) queue the migration path only if the
40
- # legacy table still exists. Otherwise queue the fresh install.
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 uses the migrate_job_locks generator for
62
- # both the fresh-install and upgrade-from-job_locks paths. The
63
- # template is idempotent: `unless table_exists?(:pgbus_uniqueness_keys)`
64
- # creates it, and `if table_exists?(:pgbus_job_locks)` drops the
65
- # legacy table. One generator covers both cases.
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:migrate_job_locks",
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, also upgrades legacy job_locks if present)",
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
- # Legacy pgbus_job_locks modern pgbus_uniqueness_keys.
132
- # The migrate_job_locks template is idempotent: it creates
133
- # uniqueness_keys if missing and drops job_locks if present.
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 need the generator (which is
184
- # idempotent via if_not_exists on both tables).
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