pgbus 0.6.7 → 0.6.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/pgbus/dashboard_controller.rb +4 -0
  3. data/app/controllers/pgbus/queues_controller.rb +1 -0
  4. data/app/views/pgbus/dashboard/_queue_health.html.erb +78 -0
  5. data/app/views/pgbus/dashboard/show.html.erb +2 -0
  6. data/app/views/pgbus/queues/show.html.erb +37 -0
  7. data/config/locales/da.yml +35 -4
  8. data/config/locales/de.yml +35 -4
  9. data/config/locales/en.yml +35 -4
  10. data/config/locales/es.yml +35 -4
  11. data/config/locales/fi.yml +35 -4
  12. data/config/locales/fr.yml +35 -4
  13. data/config/locales/it.yml +35 -4
  14. data/config/locales/ja.yml +35 -4
  15. data/config/locales/nb.yml +35 -4
  16. data/config/locales/nl.yml +35 -4
  17. data/config/locales/pt.yml +35 -4
  18. data/config/locales/sv.yml +35 -4
  19. data/lib/generators/pgbus/add_failed_events_index_generator.rb +4 -11
  20. data/lib/generators/pgbus/add_job_locks_generator.rb +4 -11
  21. data/lib/generators/pgbus/add_job_stats_generator.rb +4 -11
  22. data/lib/generators/pgbus/add_job_stats_latency_generator.rb +4 -11
  23. data/lib/generators/pgbus/add_job_stats_queue_index_generator.rb +4 -11
  24. data/lib/generators/pgbus/add_outbox_generator.rb +4 -11
  25. data/lib/generators/pgbus/add_presence_generator.rb +4 -11
  26. data/lib/generators/pgbus/add_queue_states_generator.rb +4 -11
  27. data/lib/generators/pgbus/add_recurring_generator.rb +4 -11
  28. data/lib/generators/pgbus/add_stream_stats_generator.rb +4 -11
  29. data/lib/generators/pgbus/install_generator.rb +4 -11
  30. data/lib/generators/pgbus/migrate_job_locks_generator.rb +4 -11
  31. data/lib/generators/pgbus/migration_path.rb +28 -0
  32. data/lib/generators/pgbus/templates/migration.rb.erb +6 -0
  33. data/lib/generators/pgbus/templates/tune_autovacuum.rb.erb +38 -0
  34. data/lib/generators/pgbus/tune_autovacuum_generator.rb +48 -0
  35. data/lib/generators/pgbus/upgrade_pgmq_generator.rb +4 -11
  36. data/lib/pgbus/autovacuum_tuning.rb +93 -0
  37. data/lib/pgbus/client.rb +13 -1
  38. data/lib/pgbus/engine.rb +1 -0
  39. data/lib/pgbus/generators/migration_detector.rb +38 -3
  40. data/lib/pgbus/version.rb +1 -1
  41. data/lib/pgbus/web/data_source.rb +133 -1
  42. data/lib/pgbus/web/metrics_serializer.rb +71 -2
  43. data/lib/tasks/pgbus_autovacuum.rake +40 -0
  44. metadata +7 -1
@@ -2,11 +2,13 @@
2
2
 
3
3
  require "rails/generators"
4
4
  require "rails/generators/active_record"
5
+ require_relative "migration_path"
5
6
 
6
7
  module Pgbus
7
8
  module Generators
8
9
  class InstallGenerator < Rails::Generators::Base
9
10
  include ActiveRecord::Generators::Migration
11
+ include MigrationPath
10
12
 
11
13
  source_root File.expand_path("templates", __dir__)
12
14
 
@@ -25,13 +27,8 @@ module Pgbus
25
27
  "Migrations go to db/pgbus_migrate/ and schema to db/pgbus_schema.rb"
26
28
 
27
29
  def create_migration_file
28
- if separate_database?
29
- migration_template "migration.rb.erb",
30
- "db/pgbus_migrate/create_pgbus_tables.rb"
31
- else
32
- migration_template "migration.rb.erb",
33
- "db/migrate/create_pgbus_tables.rb"
34
- end
30
+ migration_template "migration.rb.erb",
31
+ File.join(pgbus_migrate_path, "create_pgbus_tables.rb")
35
32
  end
36
33
 
37
34
  def create_config_file
@@ -121,10 +118,6 @@ module Pgbus
121
118
  def database_name
122
119
  options[:database]
123
120
  end
124
-
125
- def separate_database?
126
- database_name.present?
127
- end
128
121
  end
129
122
  end
130
123
  end
@@ -2,11 +2,13 @@
2
2
 
3
3
  require "rails/generators"
4
4
  require "rails/generators/active_record"
5
+ require_relative "migration_path"
5
6
 
6
7
  module Pgbus
7
8
  module Generators
8
9
  class MigrateJobLocksGenerator < Rails::Generators::Base
9
10
  include ActiveRecord::Generators::Migration
11
+ include MigrationPath
10
12
 
11
13
  source_root File.expand_path("templates", __dir__)
12
14
 
@@ -18,13 +20,8 @@ module Pgbus
18
20
  desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
19
21
 
20
22
  def create_migration_file
21
- if separate_database?
22
- migration_template "migrate_job_locks_to_uniqueness_keys.rb.erb",
23
- "db/pgbus_migrate/migrate_pgbus_job_locks_to_uniqueness_keys.rb"
24
- else
25
- migration_template "migrate_job_locks_to_uniqueness_keys.rb.erb",
26
- "db/migrate/migrate_pgbus_job_locks_to_uniqueness_keys.rb"
27
- end
23
+ migration_template "migrate_job_locks_to_uniqueness_keys.rb.erb",
24
+ File.join(pgbus_migrate_path, "migrate_pgbus_job_locks_to_uniqueness_keys.rb")
28
25
  end
29
26
 
30
27
  def display_post_install
@@ -47,10 +44,6 @@ module Pgbus
47
44
  def migration_version
48
45
  "[#{ActiveRecord::Migration.current_version}]"
49
46
  end
50
-
51
- def separate_database?
52
- options[:database].present?
53
- end
54
47
  end
55
48
  end
56
49
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Generators
5
+ # Shared migration path logic for all pgbus generators.
6
+ #
7
+ # When --database is passed, uses Rails' built-in db_migrate_path which
8
+ # reads migrations_paths from database.yml. This is the standard Rails
9
+ # multi-database mechanism and respects custom paths like db/pgbus_migrate/.
10
+ #
11
+ # Falls back to db/migrate/ when no --database is set (single-database mode).
12
+ module MigrationPath
13
+ private
14
+
15
+ def pgbus_migrate_path
16
+ if separate_database?
17
+ db_migrate_path
18
+ else
19
+ "db/migrate"
20
+ end
21
+ end
22
+
23
+ def separate_database?
24
+ options[:database].present?
25
+ end
26
+ end
27
+ end
28
+ end
@@ -150,6 +150,12 @@ class CreatePgbusTables < ActiveRecord::Migration<%= migration_version %>
150
150
  # Create default queues via PGMQ
151
151
  execute "SELECT pgmq.create('pgbus_default')"
152
152
  execute "SELECT pgmq.create('pgbus_default_dlq')"
153
+
154
+ # Tune autovacuum for queue/archive tables and high-churn pgbus tables.
155
+ # Default settings are too conservative for the insert/delete churn of
156
+ # queue processing and concurrency lock management.
157
+ execute Pgbus::AutovacuumTuning.sql_for_all_queues
158
+ execute Pgbus::AutovacuumTuning.sql_for_high_churn_tables
153
159
  end
154
160
 
155
161
  def down
@@ -0,0 +1,38 @@
1
+ class TunePgbusAutovacuum < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ # Apply aggressive autovacuum settings to all existing PGMQ queue and
4
+ # archive tables. Queue tables have very high insert/delete churn from
5
+ # the read→process→archive cycle; default autovacuum settings (vacuum
6
+ # at 20% dead tuples) allow dead tuple accumulation that bloats indexes
7
+ # and degrades lock acquisition times.
8
+ #
9
+ # New queues created after this migration automatically receive these
10
+ # settings via Pgbus::Client at queue creation time.
11
+ execute Pgbus::AutovacuumTuning.sql_for_all_queues
12
+
13
+ # Also tune pgbus-owned tables with high write churn:
14
+ # - pgbus_semaphores: rapid upsert+increment per job, periodic expiry
15
+ # - pgbus_uniqueness_keys: INSERT on enqueue, DELETE on completion
16
+ # - pgbus_processed_events: INSERT per event, bulk DELETE on TTL expiry
17
+ execute Pgbus::AutovacuumTuning.sql_for_high_churn_tables
18
+ end
19
+
20
+ def down
21
+ # Reset to PostgreSQL defaults
22
+ execute <<~SQL
23
+ DO $$
24
+ DECLARE
25
+ q RECORD;
26
+ BEGIN
27
+ FOR q IN SELECT queue_name FROM pgmq.meta LOOP
28
+ EXECUTE format('ALTER TABLE pgmq.q_%I RESET (autovacuum_vacuum_scale_factor, autovacuum_vacuum_cost_delay, autovacuum_analyze_scale_factor)', q.queue_name);
29
+ EXECUTE format('ALTER TABLE pgmq.a_%I RESET (autovacuum_vacuum_scale_factor, autovacuum_vacuum_cost_delay, autovacuum_analyze_scale_factor)', q.queue_name);
30
+ END LOOP;
31
+ END $$;
32
+ SQL
33
+
34
+ Pgbus::AutovacuumTuning::HIGH_CHURN_TABLES.each do |table|
35
+ execute "ALTER TABLE #{table} RESET (autovacuum_vacuum_scale_factor, autovacuum_vacuum_cost_delay, autovacuum_analyze_scale_factor)"
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+ require_relative "migration_path"
6
+
7
+ module Pgbus
8
+ module Generators
9
+ class TuneAutovacuumGenerator < Rails::Generators::Base
10
+ include ActiveRecord::Generators::Migration
11
+ include MigrationPath
12
+
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ desc "Tune autovacuum settings on PGMQ queue and archive tables for optimal queue health"
16
+
17
+ class_option :database,
18
+ type: :string,
19
+ default: nil,
20
+ desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
21
+
22
+ def create_migration_file
23
+ migration_template "tune_autovacuum.rb.erb",
24
+ File.join(pgbus_migrate_path, "tune_pgbus_autovacuum.rb")
25
+ end
26
+
27
+ def display_post_install
28
+ say ""
29
+ say "Pgbus autovacuum tuning migration created!", :green
30
+ say ""
31
+ say "This migration applies aggressive autovacuum settings to all existing"
32
+ say "PGMQ queue and archive tables. New queues created at runtime will"
33
+ say "automatically receive these settings."
34
+ say ""
35
+ say "Next steps:"
36
+ say " 1. Run: rails db:migrate#{":#{options[:database]}" if separate_database?}"
37
+ say " 2. Restart pgbus: bin/pgbus start"
38
+ say ""
39
+ end
40
+
41
+ private
42
+
43
+ def migration_version
44
+ "[#{ActiveRecord::Migration.current_version}]"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -2,11 +2,13 @@
2
2
 
3
3
  require "rails/generators"
4
4
  require "rails/generators/active_record"
5
+ require_relative "migration_path"
5
6
 
6
7
  module Pgbus
7
8
  module Generators
8
9
  class UpgradePgmqGenerator < Rails::Generators::Base
9
10
  include ActiveRecord::Generators::Migration
11
+ include MigrationPath
10
12
 
11
13
  source_root File.expand_path("templates", __dir__)
12
14
 
@@ -18,13 +20,8 @@ module Pgbus
18
20
  desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
19
21
 
20
22
  def create_migration_file
21
- if separate_database?
22
- migration_template "upgrade_pgmq.rb.erb",
23
- "db/pgbus_migrate/upgrade_pgmq_to_v#{target_version_slug}.rb"
24
- else
25
- migration_template "upgrade_pgmq.rb.erb",
26
- "db/migrate/upgrade_pgmq_to_v#{target_version_slug}.rb"
27
- end
23
+ migration_template "upgrade_pgmq.rb.erb",
24
+ File.join(pgbus_migrate_path, "upgrade_pgmq_to_v#{target_version_slug}.rb")
28
25
  end
29
26
 
30
27
  def display_post_upgrade
@@ -51,10 +48,6 @@ module Pgbus
51
48
  def target_version_slug
52
49
  target_version.tr(".", "_")
53
50
  end
54
-
55
- def separate_database?
56
- options[:database].present?
57
- end
58
51
  end
59
52
  end
60
53
  end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ # Shared autovacuum storage parameters for tables with high write churn.
5
+ #
6
+ # Queue tables (q_*) have high insert/delete churn: every read + archive
7
+ # cycle deletes from q_ and inserts into a_. Default autovacuum settings
8
+ # (vacuum at 20% dead tuples) are far too conservative — dead tuples
9
+ # accumulate, bloat B-tree indexes, and eventually degrade lock acquisition
10
+ # times. See: https://planetscale.com/blog/keeping-a-postgres-queue-healthy
11
+ #
12
+ # Several pgbus-owned tables share similar churn patterns:
13
+ # - pgbus_semaphores: rapid upsert+increment per job, periodic expiry
14
+ # - pgbus_uniqueness_keys: INSERT on enqueue, DELETE on completion
15
+ # - pgbus_processed_events: INSERT per event, bulk DELETE on TTL expiry
16
+ #
17
+ # Used by:
18
+ # - Client#ensure_single_queue (runtime, on queue creation)
19
+ # - CreatePgbusTables migration (fresh install)
20
+ # - TunePgbusAutovacuum migration (upgrade for existing installations)
21
+ module AutovacuumTuning
22
+ # Queue tables: very aggressive — high delete rate from read+archive.
23
+ QUEUE_SETTINGS = {
24
+ "autovacuum_vacuum_scale_factor" => "0.01",
25
+ "autovacuum_vacuum_cost_delay" => "2",
26
+ "autovacuum_analyze_scale_factor" => "0.05"
27
+ }.freeze
28
+
29
+ # Archive tables: moderately aggressive — append-heavy with periodic purge.
30
+ ARCHIVE_SETTINGS = {
31
+ "autovacuum_vacuum_scale_factor" => "0.05",
32
+ "autovacuum_vacuum_cost_delay" => "5",
33
+ "autovacuum_analyze_scale_factor" => "0.05"
34
+ }.freeze
35
+
36
+ # High-churn pgbus tables: rapid INSERT/DELETE or upsert cycles.
37
+ # - semaphores: upsert + increment per job acquire, decrement on release, periodic expiry
38
+ # - uniqueness_keys: INSERT on enqueue, DELETE on job completion (fast lifecycle)
39
+ # - processed_events: INSERT per event handler, bulk DELETE on idempotency TTL expiry
40
+ HIGH_CHURN_SETTINGS = {
41
+ "autovacuum_vacuum_scale_factor" => "0.02",
42
+ "autovacuum_vacuum_cost_delay" => "2",
43
+ "autovacuum_analyze_scale_factor" => "0.05"
44
+ }.freeze
45
+
46
+ HIGH_CHURN_TABLES = %w[
47
+ pgbus_semaphores
48
+ pgbus_uniqueness_keys
49
+ pgbus_processed_events
50
+ ].freeze
51
+
52
+ class << self
53
+ # Generate ALTER TABLE SQL for a single queue's tables.
54
+ def sql_for_queue(queue_name)
55
+ [
56
+ alter_table_sql("pgmq.q_#{queue_name}", QUEUE_SETTINGS),
57
+ alter_table_sql("pgmq.a_#{queue_name}", ARCHIVE_SETTINGS)
58
+ ].join("\n")
59
+ end
60
+
61
+ # Generate ALTER TABLE SQL for all queues discovered via pgmq.meta.
62
+ def sql_for_all_queues
63
+ <<~SQL
64
+ DO $$
65
+ DECLARE
66
+ q RECORD;
67
+ BEGIN
68
+ FOR q IN SELECT queue_name FROM pgmq.meta LOOP
69
+ EXECUTE format('ALTER TABLE pgmq.q_%I SET (#{settings_clause(QUEUE_SETTINGS)})', q.queue_name);
70
+ EXECUTE format('ALTER TABLE pgmq.a_%I SET (#{settings_clause(ARCHIVE_SETTINGS)})', q.queue_name);
71
+ END LOOP;
72
+ END $$;
73
+ SQL
74
+ end
75
+
76
+ # Generate ALTER TABLE SQL for pgbus-owned high-churn tables.
77
+ def sql_for_high_churn_tables
78
+ HIGH_CHURN_TABLES.map { |table| alter_table_sql(table, HIGH_CHURN_SETTINGS, if_exists: true) }.join("\n")
79
+ end
80
+
81
+ private
82
+
83
+ def alter_table_sql(table, settings, if_exists: false)
84
+ prefix = if_exists ? "ALTER TABLE IF EXISTS" : "ALTER TABLE"
85
+ "#{prefix} #{table} SET (#{settings_clause(settings)});"
86
+ end
87
+
88
+ def settings_clause(settings)
89
+ settings.map { |k, v| "#{k} = #{v}" }.join(", ")
90
+ end
91
+ end
92
+ end
93
+ end
data/lib/pgbus/client.rb CHANGED
@@ -75,7 +75,10 @@ module Pgbus
75
75
  return if @queues_created[dlq_name]
76
76
 
77
77
  @queues_created.compute_if_absent(dlq_name) do
78
- synchronized { @pgmq.create(dlq_name) }
78
+ synchronized do
79
+ @pgmq.create(dlq_name)
80
+ tune_autovacuum(dlq_name)
81
+ end
79
82
  true
80
83
  end
81
84
  end
@@ -446,12 +449,21 @@ module Pgbus
446
449
  @queues_created.compute_if_absent(full_name) do
447
450
  synchronized do
448
451
  @pgmq.create(full_name)
452
+ tune_autovacuum(full_name)
449
453
  @pgmq.enable_notify_insert(full_name, throttle_interval_ms: NOTIFY_THROTTLE_MS) if config.listen_notify
450
454
  end
451
455
  true
452
456
  end
453
457
  end
454
458
 
459
+ def tune_autovacuum(queue_name)
460
+ with_raw_connection do |conn|
461
+ conn.exec(AutovacuumTuning.sql_for_queue(queue_name))
462
+ end
463
+ rescue StandardError => e
464
+ Pgbus.logger.debug { "[Pgbus::Client] Autovacuum tuning failed for #{queue_name}: #{e.message}" }
465
+ end
466
+
455
467
  # Serialize PGMQ operations through a mutex when sharing a connection
456
468
  # with ActiveRecord (Proc path). When pgmq-ruby owns its own connections
457
469
  # (String/Hash path), the internal connection_pool handles concurrency.
data/lib/pgbus/engine.rb CHANGED
@@ -47,6 +47,7 @@ module Pgbus
47
47
  rake_tasks do
48
48
  load File.expand_path("../tasks/pgbus_pgmq.rake", __dir__)
49
49
  load File.expand_path("../tasks/pgbus_streams.rake", __dir__)
50
+ load File.expand_path("../tasks/pgbus_autovacuum.rake", __dir__)
50
51
  end
51
52
 
52
53
  initializer "pgbus.i18n" do
@@ -73,7 +73,8 @@ module Pgbus
73
73
  add_queue_states: "pgbus:add_queue_states",
74
74
  add_outbox: "pgbus:add_outbox",
75
75
  add_recurring: "pgbus:add_recurring",
76
- add_failed_events_index: "pgbus:add_failed_events_index"
76
+ add_failed_events_index: "pgbus:add_failed_events_index",
77
+ tune_autovacuum: "pgbus:tune_autovacuum"
77
78
  }.freeze
78
79
 
79
80
  # Human-friendly description of each migration for the generator
@@ -88,7 +89,8 @@ module Pgbus
88
89
  add_queue_states: "queue states table (pause/resume)",
89
90
  add_outbox: "outbox entries table (transactional outbox)",
90
91
  add_recurring: "recurring tasks + executions tables",
91
- add_failed_events_index: "unique index on pgbus_failed_events (queue_name, msg_id)"
92
+ add_failed_events_index: "unique index on pgbus_failed_events (queue_name, msg_id)",
93
+ tune_autovacuum: "autovacuum tuning for PGMQ queue and archive tables"
92
94
  }.freeze
93
95
 
94
96
  def initialize(connection)
@@ -110,7 +112,8 @@ module Pgbus
110
112
  *queue_states_migrations,
111
113
  *outbox_migrations,
112
114
  *recurring_migrations,
113
- *failed_events_index_migrations
115
+ *failed_events_index_migrations,
116
+ *autovacuum_migrations
114
117
  ]
115
118
  end
116
119
 
@@ -193,6 +196,15 @@ module Pgbus
193
196
  [:add_failed_events_index]
194
197
  end
195
198
 
199
+ # Autovacuum tuning: check if any PGMQ queue table already has
200
+ # custom autovacuum settings applied. If not, queue the migration.
201
+ def autovacuum_migrations
202
+ return [] unless pgmq_schema_exists?
203
+ return [] if autovacuum_already_tuned?
204
+
205
+ [:tune_autovacuum]
206
+ end
207
+
196
208
  # --- schema probes -------------------------------------------------
197
209
 
198
210
  def table_exists?(name)
@@ -212,6 +224,29 @@ module Pgbus
212
224
  rescue StandardError
213
225
  false
214
226
  end
227
+
228
+ def pgmq_schema_exists?
229
+ result = connection.select_value("SELECT 1 FROM information_schema.schemata WHERE schema_name = 'pgmq'")
230
+ result.present?
231
+ rescue StandardError
232
+ false
233
+ end
234
+
235
+ def autovacuum_already_tuned?
236
+ queue_name = connection.select_value("SELECT queue_name FROM pgmq.meta ORDER BY queue_name LIMIT 1")
237
+ return true unless queue_name # no queues = nothing to tune, skip
238
+
239
+ result = connection.select_value(<<~SQL)
240
+ SELECT reloptions::text LIKE '%autovacuum_vacuum_scale_factor%'
241
+ FROM pg_class
242
+ WHERE relname = 'q_#{queue_name}'
243
+ AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'pgmq')
244
+ SQL
245
+
246
+ [true, "t"].include?(result)
247
+ rescue StandardError
248
+ true # if we can't tell, assume already tuned (safe default)
249
+ end
215
250
  end
216
251
  end
217
252
  end
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.6.7"
4
+ VERSION = "0.6.9"
5
5
  end
@@ -21,6 +21,8 @@ module Pgbus
21
21
 
22
22
  throughput = compute_throughput(queues)
23
23
 
24
+ health = queue_health_stats
25
+
24
26
  {
25
27
  total_queues: queues.size,
26
28
  total_depth: total_depth,
@@ -29,7 +31,10 @@ module Pgbus
29
31
  failed_count: failed_events_count,
30
32
  dlq_depth: dlq_depth,
31
33
  recurring_count: recurring_tasks_count,
32
- throughput_rate: throughput
34
+ throughput_rate: throughput,
35
+ total_dead_tuples: health[:total_dead_tuples],
36
+ tables_needing_vacuum: health[:tables_needing_vacuum],
37
+ oldest_transaction_age_sec: health[:oldest_transaction_age_sec]
33
38
  }
34
39
  end
35
40
 
@@ -629,6 +634,50 @@ module Pgbus
629
634
  []
630
635
  end
631
636
 
637
+ # Queue health — vacuum stats, dead tuples, bloat, MVCC horizon.
638
+ # Returns aggregate health across all queue and archive tables, plus
639
+ # the oldest open transaction age (MVCC horizon pinning risk).
640
+ def queue_health_stats
641
+ tables = fetch_all_table_stats
642
+
643
+ total_dead = tables.sum { |t| t[:dead_tuples] }
644
+ total_live = tables.sum { |t| t[:live_tuples] }
645
+ worst_bloat = tables.map { |t| t[:bloat_ratio] }.max || 0.0
646
+ needs_vacuum = tables.count { |t| t[:bloat_ratio] > 0.1 }
647
+ oldest_vacuum = tables.filter_map { |t| t[:last_vacuum_ago_sec] }.max
648
+
649
+ {
650
+ total_dead_tuples: total_dead,
651
+ total_live_tuples: total_live,
652
+ worst_bloat_ratio: worst_bloat.round(4),
653
+ tables_needing_vacuum: needs_vacuum,
654
+ oldest_vacuum_ago_sec: oldest_vacuum,
655
+ oldest_transaction_age_sec: oldest_transaction_age,
656
+ tables: tables
657
+ }
658
+ rescue StandardError => e
659
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching queue health stats: #{e.class}: #{e.message}" }
660
+ {
661
+ total_dead_tuples: 0, total_live_tuples: 0, worst_bloat_ratio: 0.0,
662
+ tables_needing_vacuum: 0, oldest_vacuum_ago_sec: nil,
663
+ oldest_transaction_age_sec: nil, tables: []
664
+ }
665
+ end
666
+
667
+ # Per-queue health stats for the queue detail view.
668
+ def queue_health_detail(queue_name)
669
+ sanitized = sanitize_name(queue_name)
670
+ tables = [
671
+ fetch_table_stats("pgmq", "q_#{sanitized}", "queue"),
672
+ fetch_table_stats("pgmq", "a_#{sanitized}", "archive")
673
+ ].compact
674
+
675
+ { tables: tables, oldest_transaction_age_sec: oldest_transaction_age }
676
+ rescue StandardError => e
677
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching health detail for #{queue_name}: #{e.message}" }
678
+ { tables: [], oldest_transaction_age_sec: nil }
679
+ end
680
+
632
681
  # Stream stats — only populated when streams_stats_enabled is
633
682
  # true AND the migration has been run. Controllers should gate
634
683
  # rendering on `stream_stats_available?` to avoid showing empty
@@ -674,6 +723,89 @@ module Pgbus
674
723
  Pgbus::BusRecord.connection
675
724
  end
676
725
 
726
+ # Single query to fetch pg_stat_user_tables stats for all queue and
727
+ # archive tables. Avoids 2*N catalog queries on the dashboard.
728
+ def fetch_all_table_stats
729
+ rows = connection.select_all(<<~SQL, "Pgbus All Table Health")
730
+ WITH rels AS (
731
+ SELECT queue_name, 'q_' || queue_name AS relname, 'queue' AS kind FROM pgmq.meta
732
+ UNION ALL
733
+ SELECT queue_name, 'a_' || queue_name AS relname, 'archive' AS kind FROM pgmq.meta
734
+ )
735
+ SELECT
736
+ 'pgmq.' || r.relname AS table_name,
737
+ r.kind,
738
+ s.n_live_tup,
739
+ s.n_dead_tup,
740
+ EXTRACT(epoch FROM (NOW() - COALESCE(s.last_vacuum, s.last_autovacuum)))::int AS last_vacuum_ago_sec,
741
+ s.last_vacuum,
742
+ s.last_autovacuum
743
+ FROM rels r
744
+ LEFT JOIN pg_stat_user_tables s
745
+ ON s.schemaname = 'pgmq' AND s.relname = r.relname
746
+ ORDER BY r.queue_name, r.kind
747
+ SQL
748
+
749
+ rows.to_a.filter_map { |row| build_table_health_row(row) }
750
+ end
751
+
752
+ # Fetch pg_stat_user_tables stats for a single table (used by queue_health_detail).
753
+ def fetch_table_stats(schema, table_name, kind)
754
+ row = connection.select_one(<<~SQL, "Pgbus Table Health", [schema, table_name])
755
+ SELECT
756
+ n_live_tup,
757
+ n_dead_tup,
758
+ EXTRACT(epoch FROM (NOW() - COALESCE(last_vacuum, last_autovacuum)))::int AS last_vacuum_ago_sec,
759
+ last_vacuum,
760
+ last_autovacuum
761
+ FROM pg_stat_user_tables
762
+ WHERE schemaname = $1 AND relname = $2
763
+ SQL
764
+
765
+ return nil unless row
766
+
767
+ build_table_health_row(row.merge("table_name" => "#{schema}.#{table_name}", "kind" => kind))
768
+ end
769
+
770
+ def build_table_health_row(row)
771
+ return nil unless row["n_live_tup"] || row["n_dead_tup"]
772
+
773
+ live = row["n_live_tup"].to_i
774
+ dead = row["n_dead_tup"].to_i
775
+ total = live + dead
776
+ bloat = total.positive? ? (dead.to_f / total) : 0.0
777
+
778
+ {
779
+ table: row["table_name"],
780
+ kind: row["kind"],
781
+ live_tuples: live,
782
+ dead_tuples: dead,
783
+ bloat_ratio: bloat.round(4),
784
+ last_vacuum_ago_sec: row["last_vacuum_ago_sec"]&.to_i,
785
+ last_vacuum: row["last_vacuum"],
786
+ last_autovacuum: row["last_autovacuum"]
787
+ }
788
+ end
789
+
790
+ # Age of the oldest open transaction in seconds — indicates MVCC
791
+ # horizon pinning risk. Returns nil if no active transactions.
792
+ def oldest_transaction_age
793
+ row = connection.select_one(<<~SQL, "Pgbus Oldest Transaction")
794
+ SELECT EXTRACT(epoch FROM (NOW() - xact_start))::int AS age_sec
795
+ FROM pg_stat_activity
796
+ WHERE state != 'idle'
797
+ AND xact_start IS NOT NULL
798
+ AND pid != pg_backend_pid()
799
+ ORDER BY xact_start ASC
800
+ LIMIT 1
801
+ SQL
802
+
803
+ row&.dig("age_sec")&.to_i
804
+ rescue StandardError => e
805
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching oldest transaction age: #{e.class}: #{e.message}" }
806
+ nil
807
+ end
808
+
677
809
  # name is the full PGMQ queue name (already prefixed)
678
810
  def query_queue_messages(name, limit, offset)
679
811
  query_queue_messages_raw(name, limit, offset).map { |m| m.merge(queue: name) }