pgbouncerhero 3.0.0 → 3.2.0

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +119 -4
  4. data/app/assets/builds/pgbouncerhero/application.css +2 -2
  5. data/app/assets/stylesheets/pgbouncerhero/application.css +11 -1
  6. data/app/controllers/pg_bouncer_hero/application_controller.rb +4 -3
  7. data/app/controllers/pg_bouncer_hero/database_controller.rb +93 -9
  8. data/app/helpers/pg_bouncer_hero/application_helper.rb +54 -0
  9. data/app/javascript/pgbouncerhero/controllers/data_table_controller.js +183 -0
  10. data/app/javascript/pgbouncerhero/controllers/fleet_controller.js +52 -0
  11. data/app/javascript/pgbouncerhero/controllers/polling_controller.js +49 -5
  12. data/app/views/layouts/pg_bouncer_hero/application.html.erb +1 -1
  13. data/app/views/pg_bouncer_hero/database/_actions.html.erb +1 -1
  14. data/app/views/pg_bouncer_hero/database/_clients.html.erb +9 -8
  15. data/app/views/pg_bouncer_hero/database/_conf.html.erb +10 -9
  16. data/app/views/pg_bouncer_hero/database/_databases.html.erb +30 -9
  17. data/app/views/pg_bouncer_hero/database/_menu.html.erb +19 -4
  18. data/app/views/pg_bouncer_hero/database/_pools.html.erb +10 -9
  19. data/app/views/pg_bouncer_hero/database/_servers.html.erb +31 -0
  20. data/app/views/pg_bouncer_hero/database/_state.html.erb +11 -0
  21. data/app/views/pg_bouncer_hero/database/_stats.html.erb +12 -11
  22. data/app/views/pg_bouncer_hero/database/_users.html.erb +31 -0
  23. data/app/views/pg_bouncer_hero/database/clients.html.erb +4 -2
  24. data/app/views/pg_bouncer_hero/database/conf.html.erb +4 -2
  25. data/app/views/pg_bouncer_hero/database/databases.html.erb +4 -2
  26. data/app/views/pg_bouncer_hero/database/pools.html.erb +4 -2
  27. data/app/views/pg_bouncer_hero/database/servers.html.erb +6 -0
  28. data/app/views/pg_bouncer_hero/database/state.html.erb +6 -0
  29. data/app/views/pg_bouncer_hero/database/stats.html.erb +4 -2
  30. data/app/views/pg_bouncer_hero/database/users.html.erb +6 -0
  31. data/app/views/pg_bouncer_hero/home/_card.html.erb +5 -2
  32. data/app/views/pg_bouncer_hero/home/_card_content.html.erb +48 -31
  33. data/app/views/pg_bouncer_hero/home/index.html.erb +28 -8
  34. data/app/views/shared/_data_table_toolbar.html.erb +13 -0
  35. data/config/routes.rb +16 -2
  36. data/lib/generators/pgbouncerhero/templates/config.yml +7 -0
  37. data/lib/pgbouncerhero/connection.rb +54 -13
  38. data/lib/pgbouncerhero/database.rb +73 -3
  39. data/lib/pgbouncerhero/methods/basics.rb +86 -10
  40. data/lib/pgbouncerhero/version.rb +1 -1
  41. data/lib/pgbouncerhero.rb +139 -38
  42. metadata +83 -3
@@ -1,4 +1,6 @@
1
1
  <%= render partial: "menu", locals: {database: @database} %>
2
- <% if @clients %>
3
- <%= render partial: "clients", locals: {database: @database, clients: @clients} %>
2
+ <%= monitoring_panel do %>
3
+ <% if @clients %>
4
+ <%= render partial: "clients", locals: {database: @database, clients: @clients} %>
5
+ <% end %>
4
6
  <% end %>
@@ -1,4 +1,6 @@
1
1
  <%= render partial: "menu", locals: {database: @database} %>
2
- <% if @conf %>
3
- <%= render partial: "conf", locals: {database: @database, conf: @conf} %>
2
+ <%= monitoring_panel do %>
3
+ <% if @conf %>
4
+ <%= render partial: "conf", locals: {database: @database, conf: @conf} %>
5
+ <% end %>
4
6
  <% end %>
@@ -1,4 +1,6 @@
1
1
  <%= render partial: "menu", locals: {database: @database} %>
2
- <% if @dbs %>
3
- <%= render partial: "databases", locals: {database: @database, databases: @dbs} %>
2
+ <%= monitoring_panel do %>
3
+ <% if @dbs %>
4
+ <%= render partial: "databases", locals: {database: @database, databases: @dbs} %>
5
+ <% end %>
4
6
  <% end %>
@@ -1,4 +1,6 @@
1
1
  <%= render partial: "menu", locals: {database: @database} %>
2
- <% if @pools %>
3
- <%= render partial: "pools", locals: {database: @database, pools: @pools} %>
2
+ <%= monitoring_panel do %>
3
+ <% if @pools %>
4
+ <%= render partial: "pools", locals: {database: @database, pools: @pools} %>
5
+ <% end %>
4
6
  <% end %>
@@ -0,0 +1,6 @@
1
+ <%= render partial: "menu", locals: {database: @database} %>
2
+ <%= monitoring_panel do %>
3
+ <% if @servers %>
4
+ <%= render partial: "servers", locals: {database: @database, servers: @servers} %>
5
+ <% end %>
6
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <%= render partial: "menu", locals: {database: @database} %>
2
+ <%= monitoring_panel do %>
3
+ <% if @state %>
4
+ <%= render partial: "state", locals: {state: @state} %>
5
+ <% end %>
6
+ <% end %>
@@ -1,4 +1,6 @@
1
1
  <%= render partial: "menu", locals: {database: @database} %>
2
- <% if @stats %>
3
- <%= render partial: "stats", locals: {database: @database, stats: @stats} %>
2
+ <%= monitoring_panel do %>
3
+ <% if @stats %>
4
+ <%= render partial: "stats", locals: {database: @database, stats: @stats} %>
5
+ <% end %>
4
6
  <% end %>
@@ -0,0 +1,6 @@
1
+ <%= render partial: "menu", locals: {database: @database} %>
2
+ <%= monitoring_panel do %>
3
+ <% if @users %>
4
+ <%= render partial: "users", locals: {database: @database, users: @users} %>
5
+ <% end %>
6
+ <% end %>
@@ -1,13 +1,16 @@
1
- <div class="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
1
+ <div class="bg-white rounded-lg border border-gray-200 shadow-xs overflow-hidden">
2
2
  <%= link_to databases_path(group: database.group.name.parameterize, database: database.name.parameterize), class: "block no-underline text-inherit" do %>
3
3
  <div class="px-4 py-3 flex items-center justify-between border-b border-gray-100">
4
4
  <div>
5
5
  <div class="text-base font-bold text-gray-900"><%= database.name %></div>
6
6
  <div class="text-xs text-gray-500"><%= database.host %></div>
7
7
  </div>
8
+ <% if database.read_only? %>
9
+ <span class="inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 ring-1 ring-gray-300 ring-inset">Read-only</span>
10
+ <% end %>
8
11
  </div>
9
12
  <% end %>
10
- <turbo-frame id="card_<%= database.group.name.parameterize %>_<%= database.name.parameterize %>" src="<%= summary_path(group: database.group.name.parameterize, database: database.name.parameterize) %>" loading="lazy">
13
+ <turbo-frame id="card_<%= database.group.name.parameterize %>_<%= database.name.parameterize %>" src="<%= summary_path(group: database.group.name.parameterize, database: database.name.parameterize) %>">
11
14
  <%= render partial: "card_loading_content" %>
12
15
  </turbo-frame>
13
16
  </div>
@@ -1,43 +1,60 @@
1
1
  <% if database.connection %>
2
2
  <% summary = database.summary %>
3
- <div class="px-4 py-3 flex items-start gap-6">
4
- <div class="space-y-1 text-sm text-gray-700">
5
- <div>
6
- <% number = summary.select { |row| row["list"] == "users" }.first["items"] %>
7
- <%= pluralize(number, "user") %>
8
- </div>
9
- <div>
10
- <% number = summary.select { |row| row["list"] == "databases" }.first["items"].to_i - 1 %>
11
- <%= pluralize(number, "database") %>
12
- </div>
13
- <div>
14
- <% number = summary.select { |row| row["list"] == "pools" }.first["items"].to_i - 1 %>
15
- <%= pluralize(number, "pool") %>
3
+ <% health = fleet_health(summary) %>
4
+ <div data-fleet-health data-status="<%= health[:status] %>" data-severity="<%= health[:severity] %>" data-waiting-clients="<%= health[:waiting_clients] %>" data-high-utilization="<%= health[:max_utilization] >= 80 %>">
5
+ <div class="px-4 py-3 flex items-start gap-6">
6
+ <div class="space-y-1 text-sm text-gray-700">
7
+ <div>
8
+ <% number = summary.select { |row| row["list"] == "users" }.first["items"] %>
9
+ <%= pluralize(number, "user") %>
10
+ </div>
11
+ <div>
12
+ <% number = summary.select { |row| row["list"] == "databases" }.first["items"].to_i - 1 %>
13
+ <%= pluralize(number, "database") %>
14
+ </div>
15
+ <div>
16
+ <% number = summary.select { |row| row["list"] == "pools" }.first["items"].to_i - 1 %>
17
+ <%= pluralize(number, "pool") %>
18
+ </div>
19
+ <div>
20
+ <% if health[:waiting_clients].positive? %>
21
+ <span class="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-800 ring-1 ring-yellow-200 ring-inset"><%= pluralize(health[:waiting_clients], "waiting client") %></span>
22
+ <% else %>
23
+ <span class="text-xs text-gray-400">No clients waiting</span>
24
+ <% end %>
25
+ </div>
16
26
  </div>
17
- </div>
18
- <div class="flex-1 space-y-2">
19
- <% summary.select { |row| row.key?(:databases_details) }.first[:databases_details].each do |db| %>
20
- <div class="flex items-center gap-2 text-sm">
21
- <span class="font-medium text-gray-900 w-24 truncate"><%= db["name"] %></span>
22
- <div class="flex-1">
23
- <% current = db["current_connections"].to_i %>
24
- <% max = db["max_connections"].to_i %>
25
- <% pct = max > 0 ? (current.to_f / max * 100).round : 0 %>
26
- <div class="w-full bg-gray-200 rounded-full h-2.5">
27
- <div class="bg-green-500 h-2.5 rounded-full" style="width: <%= pct %>%"></div>
27
+ <div class="flex-1 space-y-2">
28
+ <% summary.select { |row| row.key?(:databases_details) }.first[:databases_details].each do |db| %>
29
+ <div class="flex items-center gap-2 text-sm">
30
+ <span class="font-medium text-gray-900 w-24 truncate"><%= db["name"] %></span>
31
+ <div class="flex-1">
32
+ <% current = db["current_connections"].to_i %>
33
+ <% max = db["max_connections"].to_i %>
34
+ <% pct = max > 0 ? (current.to_f / max * 100).round : 0 %>
35
+ <div class="w-full bg-gray-200 rounded-full h-2.5">
36
+ <div class="<%= pct >= 80 ? "bg-amber-500" : "bg-green-500" %> h-2.5 rounded-full" style="width: <%= [pct, 100].min %>%"></div>
37
+ </div>
38
+ <div class="text-xs text-gray-500 mt-0.5"><%= current %> / <%= max %> connections</div>
28
39
  </div>
29
- <div class="text-xs text-gray-500 mt-0.5"><%= current %> / <%= max %> connections</div>
30
40
  </div>
31
- </div>
41
+ <% end %>
42
+ </div>
43
+ </div>
44
+ <div class="px-4 py-2 border-t border-gray-100 flex items-center justify-between">
45
+ <% if health[:waiting_clients].positive? %>
46
+ <span class="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-800 ring-1 ring-yellow-200 ring-inset"><%= pluralize(health[:waiting_clients], "client") %> waiting</span>
47
+ <% elsif health[:max_utilization] >= 80 %>
48
+ <span class="inline-flex items-center rounded-md bg-amber-50 px-2 py-1 text-xs font-medium text-amber-800 ring-1 ring-amber-300 ring-inset"><%= health[:max_utilization] %>% utilized</span>
49
+ <% else %>
50
+ <span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-600/20 ring-inset">Healthy</span>
32
51
  <% end %>
52
+ <span class="text-xs text-gray-400">Last refresh: <%= Time.now.strftime("%H:%M:%S %Z") %></span>
33
53
  </div>
34
54
  </div>
35
- <div class="px-4 py-2 border-t border-gray-100 flex items-center justify-between">
36
- <span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-600/20 ring-inset">Online</span>
37
- <span class="text-xs text-gray-400">Last refresh: <%= Time.now.strftime("%H:%M:%S %Z") %></span>
38
- </div>
39
55
  <% else %>
40
- <div class="px-4 py-6 text-center">
56
+ <% health = fleet_health(nil) %>
57
+ <div class="px-4 py-6 text-center" data-fleet-health data-status="<%= health[:status] %>" data-severity="<%= health[:severity] %>" data-waiting-clients="0" data-high-utilization="false">
41
58
  <span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-red-600/20 ring-inset">Offline</span>
42
59
  </div>
43
60
  <% end %>
@@ -1,18 +1,38 @@
1
- <div class="bg-white rounded-lg border border-gray-200 shadow-sm mb-6">
2
- <div class="px-4 py-3 border-b border-gray-200">
3
- <h2 class="text-lg font-semibold text-gray-900">Overview</h2>
1
+ <div data-controller="fleet polling" data-polling-interval-value="60000" data-fleet-total-value="<%= @groups.values.sum { |group| group.databases.size } %>" data-action="turbo:frame-load->fleet#refresh turbo:frame-load->polling#refreshed">
2
+ <div class="bg-white rounded-lg border border-gray-200 shadow-xs mb-6">
3
+ <div class="px-4 py-3 border-b border-gray-200 flex flex-wrap items-center justify-between gap-2">
4
+ <div>
5
+ <h2 class="text-lg font-semibold text-gray-900">Fleet health</h2>
6
+ <p class="text-sm text-gray-500" data-fleet-target="headline" aria-live="polite">Checking <%= pluralize(@groups.values.sum { |group| group.databases.size }, "instance") %>…</p>
7
+ </div>
8
+ <span class="text-xs text-gray-400" data-polling-target="status">Loading current status…</span>
9
+ </div>
10
+ <div class="grid grid-cols-1 sm:grid-cols-3 divide-y sm:divide-y-0 sm:divide-x divide-gray-200">
11
+ <div class="px-4 py-3">
12
+ <div class="text-xs font-medium uppercase tracking-wider text-gray-500">Offline</div>
13
+ <div class="text-xl font-semibold text-red-700" data-fleet-target="offline">—</div>
14
+ </div>
15
+ <div class="px-4 py-3">
16
+ <div class="text-xs font-medium uppercase tracking-wider text-gray-500">Waiting clients</div>
17
+ <div class="text-xl font-semibold text-yellow-800" data-fleet-target="waiting">—</div>
18
+ </div>
19
+ <div class="px-4 py-3">
20
+ <div class="text-xs font-medium uppercase tracking-wider text-gray-500">High utilization</div>
21
+ <div class="text-xl font-semibold text-amber-800" data-fleet-target="utilization">—</div>
22
+ </div>
23
+ </div>
4
24
  </div>
5
- </div>
6
25
 
7
- <div data-controller="polling" data-polling-interval-value="60000">
8
26
  <% @groups.each do |_, group| %>
9
27
  <div class="mb-8">
10
28
  <h3 class="text-xl font-semibold text-gray-900 mb-4 pb-2 border-b border-gray-200">
11
29
  <%= group.name %>
12
30
  </h3>
13
- <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
14
- <% group.databases.each do |database| %>
15
- <%= render partial: "card", locals: { database: database } %>
31
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4" data-fleet-target="group">
32
+ <% group.databases.each_with_index do |database, index| %>
33
+ <div data-fleet-target="item" data-fleet-original-index="<%= index %>">
34
+ <%= render partial: "card", locals: { database: database } %>
35
+ </div>
16
36
  <% end %>
17
37
  </div>
18
38
  </div>
@@ -0,0 +1,13 @@
1
+ <div class="flex items-start gap-3 flex-wrap">
2
+ <label class="sr-only" for="<%= table_id %>_search">Search <%= title.downcase %></label>
3
+ <input id="<%= table_id %>_search" type="search" placeholder="Search <%= title.downcase %>…" class="w-56 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700" data-data-table-target="query" data-action="input->data-table#filter">
4
+ <details class="relative text-sm text-gray-700">
5
+ <summary class="cursor-pointer rounded-md border border-gray-300 bg-white px-3 py-1.5 font-medium">Columns</summary>
6
+ <div class="absolute right-0 z-20 mt-2 w-max min-w-64 rounded-md border border-gray-200 bg-white p-3 text-xs shadow-lg">
7
+ <div class="grid grid-cols-2 gap-x-4 gap-y-2" data-data-table-target="columnMenu"></div>
8
+ <button type="button" class="mt-3 w-full border-t border-gray-200 pt-2 text-left font-medium text-gray-600 hover:text-gray-900" data-action="data-table#resetColumns">Reset columns</button>
9
+ </div>
10
+ </details>
11
+ <span class="py-1.5 text-xs text-gray-500" data-data-table-target="status"><%= row_count %> rows</span>
12
+ </div>
13
+ <p class="mt-3 text-sm text-gray-500" data-data-table-target="empty" hidden>No rows match this search.</p>
data/config/routes.rb CHANGED
@@ -1,15 +1,29 @@
1
1
  PgBouncerHero::Engine.routes.draw do
2
+ group_exists = ->(request) { PgBouncerHero.groups.key?(request.params[:group]) }
3
+ database_exists = lambda do |request|
4
+ group = PgBouncerHero.groups[request.params[:group]]
5
+ group&.databases&.any? { |database| database.name.parameterize == request.params[:database] }
6
+ end
7
+
2
8
  root to: "home#index"
3
- scope path: ":group", constraints: proc { |req| (PgBouncerHero.groups.keys.map(&:parameterize) + [ nil ]).include?(req.params[:group]) } do
4
- scope path: ":database", constraints: proc { |req| (PgBouncerHero.groups[req.params[:group]].databases.map(&:name).map(&:parameterize) + [ nil ]).include?(req.params[:database]) } do
9
+ scope path: ":group", constraints: group_exists do
10
+ scope path: ":database", constraints: database_exists do
5
11
  get :summary, controller: :database
6
12
  get :databases, controller: :database
7
13
  get :stats, controller: :database
8
14
  get :pools, controller: :database
9
15
  get :clients, controller: :database
16
+ get :servers, controller: :database
17
+ get :users, controller: :database
10
18
  get :conf, controller: :database
19
+ get :state, controller: :database
11
20
  post :reload, controller: :database
12
21
  post :suspend, controller: :database
22
+ post :resume, controller: :database
23
+ post :pause_database, controller: :database
24
+ post :reconnect_database, controller: :database
25
+ post :wait_close_database, controller: :database
26
+ post :resume_database, controller: :database
13
27
  post :shutdown, controller: :database
14
28
  end
15
29
  end
@@ -3,6 +3,9 @@ pgbouncers:
3
3
  primary:
4
4
  # eg. postgres://user:password@host:port/pgbouncer
5
5
  # url: <%%= ENV["PGBOUNCER_PRODUCTION_PRIMARY_DATABASE_URL"] %>
6
+ # pool_size: 5
7
+ # pool_timeout: 5
8
+ # read_only: true
6
9
  # Add more databases
7
10
  # replica:
8
11
  # url: <%%= ENV["PGBOUNCER_PRODUCTION_REPLICA_DATABASE_URL"] %>
@@ -14,3 +17,7 @@ pgbouncers:
14
17
 
15
18
  # Time zone (defaults to app time zone)
16
19
  # time_zone: "Pacific Time (US & Canada)"
20
+
21
+ # Hide and reject administrative commands for every PgBouncer unless an
22
+ # individual PgBouncer sets read_only explicitly
23
+ # read_only: true
@@ -10,19 +10,60 @@ module PgBouncerHero
10
10
  end
11
11
 
12
12
  def connection
13
- @connection ||= begin
14
- PG.connect(
15
- host: @host,
16
- port: @port,
17
- user: @user,
18
- password: @password,
19
- dbname: @dbname,
20
- connect_timeout: @timeout
21
- )
22
- rescue StandardError => e
23
- Rails.logger.error("[PGBouncerHero] Host:#{@host} | Database Name:#{@dbname} | Timeout: #{@timeout}s => #{e}")
24
- nil
25
- end
13
+ disconnect! if @connection && connection_invalid?(@connection)
14
+ @connection ||= connect
15
+ rescue StandardError => e
16
+ Rails.logger.error("[PGBouncerHero] Host:#{@host} | Database Name:#{@dbname} | Timeout: #{@timeout}s => #{e}")
17
+ nil
18
+ end
19
+
20
+ def connected?
21
+ !connection.nil?
22
+ end
23
+
24
+ def with_connection
25
+ raw_connection = connection
26
+ return unless raw_connection
27
+
28
+ yield raw_connection
29
+ rescue PG::Error
30
+ disconnect!
31
+ raise
32
+ end
33
+
34
+ def disconnect!
35
+ @connection&.finish unless @connection&.finished?
36
+ rescue PG::Error
37
+ nil
38
+ ensure
39
+ @connection = nil
40
+ end
41
+
42
+ def method_missing(method_name, *, **, &)
43
+ with_connection { |raw_connection| raw_connection.public_send(method_name, *, **, &) }
44
+ end
45
+
46
+ def respond_to_missing?(method_name, include_private = false)
47
+ PG::Connection.public_instance_methods(include_private).include?(method_name) || super
48
+ end
49
+
50
+ private
51
+
52
+ def connect
53
+ PG.connect(
54
+ host: @host,
55
+ port: @port,
56
+ user: @user,
57
+ password: @password,
58
+ dbname: @dbname,
59
+ connect_timeout: @timeout
60
+ )
61
+ end
62
+
63
+ def connection_invalid?(connection)
64
+ connection.finished? || connection.status != PG::CONNECTION_OK
65
+ rescue PG::Error
66
+ true
26
67
  end
27
68
  end
28
69
  end
@@ -1,14 +1,23 @@
1
+ require "connection_pool"
2
+ require "monitor"
3
+
1
4
  module PgBouncerHero
2
5
  class Database
3
6
  include Methods::Basics
4
7
 
5
- attr_reader :id, :config, :group
8
+ DEFAULT_POOL_SIZE = 5
9
+ DEFAULT_POOL_TIMEOUT = 5
10
+
11
+ attr_reader :id, :config, :group, :pool_size, :pool_timeout
6
12
 
7
13
  def initialize(group, id, config)
8
14
  @id = id
9
15
  @config = config || {}
10
- @url = URI.parse(config["url"].to_s)
16
+ @url = URI.parse(@config["url"].to_s)
11
17
  @group = group
18
+ @pool_size = positive_integer_setting("pool_size", "PGBOUNCERHERO_POOL_SIZE", DEFAULT_POOL_SIZE)
19
+ @pool_timeout = positive_float_setting("pool_timeout", "PGBOUNCERHERO_POOL_TIMEOUT", DEFAULT_POOL_TIMEOUT)
20
+ @pool_monitor = Monitor.new
12
21
  end
13
22
 
14
23
  def name
@@ -16,7 +25,28 @@ module PgBouncerHero
16
25
  end
17
26
 
18
27
  def connection
19
- @connection ||= connection_model.new(host, port, user, password, dbname).connection
28
+ proxy = connection_proxy
29
+ proxy if with_connection { true }
30
+ end
31
+
32
+ def with_connection
33
+ pool = connection_pool
34
+ pool.with do |managed_connection|
35
+ managed_connection.with_connection { |raw_connection| yield raw_connection }
36
+ end
37
+ rescue ConnectionPool::TimeoutError => e
38
+ Rails.logger.error("[PGBouncerHero] #{name} connection pool timed out after #{pool_timeout}s: #{e.message}")
39
+ nil
40
+ end
41
+
42
+ def disconnect!
43
+ pool = @pool_monitor.synchronize do
44
+ current_pool = @connection_pool
45
+ @connection_pool = nil
46
+ @connection_proxy = nil
47
+ current_pool
48
+ end
49
+ pool&.shutdown(&:disconnect!)
20
50
  end
21
51
 
22
52
  def host
@@ -35,12 +65,36 @@ module PgBouncerHero
35
65
  @url.password if @url
36
66
  end
37
67
 
68
+ def read_only?
69
+ PgBouncerHero.read_only_for?(self)
70
+ end
71
+
38
72
  def dbname
39
73
  @url.path[1..-1] if @url
40
74
  end
41
75
 
42
76
  private
43
77
 
78
+ def execute(command)
79
+ with_connection { |raw_connection| raw_connection.exec(command) }
80
+ end
81
+
82
+ def connection_pool
83
+ @pool_monitor.synchronize do
84
+ @connection_pool ||= ConnectionPool.new(size: pool_size, timeout: pool_timeout) { build_connection }
85
+ end
86
+ end
87
+
88
+ def connection_proxy
89
+ @pool_monitor.synchronize do
90
+ @connection_proxy ||= ConnectionPool::Wrapper.new(pool: connection_pool)
91
+ end
92
+ end
93
+
94
+ def build_connection
95
+ connection_model.new(host, port, user, password, dbname)
96
+ end
97
+
44
98
  def connection_model
45
99
  @connection_model ||= begin
46
100
  Class.new(PgBouncerHero::Connection) do
@@ -50,5 +104,21 @@ module PgBouncerHero
50
104
  end
51
105
  end
52
106
  end
107
+
108
+ def positive_integer_setting(config_key, environment_key, default)
109
+ value = config.fetch(config_key, ENV.fetch(environment_key, default))
110
+ parsed = Integer(value)
111
+ raise ArgumentError, "#{config_key} must be greater than zero" unless parsed.positive?
112
+
113
+ parsed
114
+ end
115
+
116
+ def positive_float_setting(config_key, environment_key, default)
117
+ value = config.fetch(config_key, ENV.fetch(environment_key, default))
118
+ parsed = Float(value)
119
+ raise ArgumentError, "#{config_key} must be a finite number greater than zero" unless parsed.positive? && parsed.finite?
120
+
121
+ parsed
122
+ end
53
123
  end
54
124
  end
@@ -1,42 +1,118 @@
1
1
  module PgBouncerHero
2
2
  module Methods
3
3
  module Basics
4
+ SHUTDOWN_MODES = {
5
+ nil => "",
6
+ immediate: "",
7
+ wait_for_clients: " WAIT_FOR_CLIENTS",
8
+ wait_for_servers: " WAIT_FOR_SERVERS"
9
+ }.freeze
10
+
4
11
  def summary
5
12
  if connection
6
13
  l = lists
7
14
  d = databases
15
+ p = pools
8
16
  l = l.as_json
9
17
  d = d.as_json.reject { |a| a["name"] == "pgbouncer" }
18
+ p = p.as_json
10
19
  l.push({ databases_details: d })
20
+ l.push({ pools_details: p })
11
21
  l
12
22
  end
13
23
  end
14
24
  def databases
15
- connection.exec("SHOW databases")
25
+ execute("SHOW databases")
16
26
  end
17
27
  def stats
18
- connection.exec("SHOW stats")
28
+ execute("SHOW stats")
19
29
  end
20
30
  def lists
21
- connection.exec("SHOW lists")
31
+ execute("SHOW lists")
22
32
  end
23
33
  def pools
24
- connection.exec("SHOW pools")
34
+ execute("SHOW pools")
25
35
  end
26
36
  def clients
27
- connection.exec("SHOW clients")
37
+ execute("SHOW clients")
38
+ end
39
+ def servers
40
+ execute("SHOW servers")
41
+ end
42
+ def users
43
+ execute("SHOW users")
28
44
  end
29
45
  def conf
30
- connection.exec("SHOW config")
46
+ execute("SHOW config")
47
+ end
48
+ def state
49
+ execute("SHOW state")
31
50
  end
32
51
  def reload
33
- connection.exec("RELOAD")
52
+ instrument_admin_command(:reload) { execute("RELOAD") }
34
53
  end
35
54
  def suspend
36
- connection.exec("SUSPEND")
55
+ instrument_admin_command(:suspend) { execute("SUSPEND") }
56
+ end
57
+ def pause(database_name)
58
+ execute_database_command(:pause, "PAUSE", database_name)
59
+ end
60
+ def reconnect(database_name)
61
+ execute_database_command(:reconnect, "RECONNECT", database_name)
62
+ end
63
+ def wait_close(database_name)
64
+ execute_database_command(:wait_close, "WAIT_CLOSE", database_name)
37
65
  end
38
- def shutdown
39
- connection.exec("SHUTDOWN")
66
+ def resume(database_name = nil)
67
+ return instrument_admin_command(:resume) { execute("RESUME") } if database_name.nil?
68
+
69
+ execute_database_command(:resume, "RESUME", database_name)
70
+ end
71
+ def shutdown(mode = nil)
72
+ instrument_admin_command(:shutdown, mode: mode || :immediate) do
73
+ suffix = SHUTDOWN_MODES.fetch(mode)
74
+ execute("SHUTDOWN#{suffix}")
75
+ rescue KeyError
76
+ raise ArgumentError, "unsupported shutdown mode: #{mode.inspect}"
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def execute_database_command(action, command, database_name)
83
+ name = database_name.to_s
84
+ instrument_admin_command(action, target_database: name) do
85
+ raise ArgumentError, "database name must not be empty" if name.empty?
86
+
87
+ execute("#{command} #{PG::Connection.quote_ident(name)}")
88
+ end
89
+ end
90
+
91
+ def instrument_admin_command(action, target_database: nil, **attributes)
92
+ payload = {
93
+ group: group.name,
94
+ database: name,
95
+ action: action,
96
+ target_database: target_database,
97
+ **attributes
98
+ }
99
+
100
+ error = nil
101
+ result = ActiveSupport::Notifications.instrument(PgBouncerHero::ADMIN_COMMAND_EVENT, payload) do
102
+ begin
103
+ command_result = yield
104
+ payload[:outcome] = command_result.nil? ? :unavailable : :success
105
+ command_result
106
+ rescue StandardError => command_error
107
+ error = command_error
108
+ payload[:outcome] = :error
109
+ payload[:error_class] = command_error.class.name
110
+ nil
111
+ end
112
+ end
113
+ raise error if error
114
+
115
+ result
40
116
  end
41
117
  end
42
118
  end
@@ -1,3 +1,3 @@
1
1
  module PgBouncerHero
2
- VERSION = "3.0.0"
2
+ VERSION = "3.2.0"
3
3
  end