pgbouncerhero 3.0.0 → 3.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 269d1da746f222ec69b19046e30b54eda65b082be5be4df7177dc44007c71042
4
- data.tar.gz: 9653ffded27e1f8580f6de55f2385fedcd0ac3e52ce5fa607a1cab5bcff3b766
3
+ metadata.gz: 913492602328ce8fb2d3731212ccd7cef95c2a9429fbe6d4112d9a65d5853b35
4
+ data.tar.gz: 5475f266d924090db51b7a242cc7a7e9b90e917dd485e562f346d7f471d3e9bf
5
5
  SHA512:
6
- metadata.gz: 946980cd130e213f922110a77258beda36d4973cd9d3215f840bb62fde4960c0e84e479076c8dc5853b4c16f69c67b5546d3dd61f21e9e009ed0b1f15f422625
7
- data.tar.gz: 85d1f29c93363ff5dafe39cd0140229b40f623cd5c9433e1c68c4e18a9181e4eaa0bbc0765b35ed03bf981521c6995c0c9ae3d46b1b0737cc595cfa13ab3eb43
6
+ metadata.gz: 0e6ceb49a3f0c1b28a2edeb0939fdc7bd9f4284c02c5361f879ecedd7cb9af17f1862f5943fb75b9c79e03f5d75d0242fefc4c8ee7b67cf43b62c1ee0414c345
7
+ data.tar.gz: 96bc7a41d920340e9fce91c6686b6819a71393713b8e4e559e95cf790ebcae3b5d7fba13a29b62d32342aced8dceb8376c0db92b2c29f3678252199ae4c2f4e4
data/CHANGELOG.md CHANGED
@@ -1,3 +1,31 @@
1
+ ## Unreleased
2
+
3
+ ## 3.1.0 - 2026-08-23
4
+
5
+ **New:**
6
+ - Read-only mode for hiding and server-side blocking PgBouncer administrative commands
7
+ - PgBouncer state visibility and Resume controls
8
+ - Integration coverage across supported PgBouncer 1.23, 1.24, and 1.25 releases
9
+ - Bounded, thread-safe connection pools per PgBouncer with configurable size and checkout timeout
10
+ - Explicit `Database#with_connection` leases for running multiple commands on one connection
11
+ - Real PgBouncer integration coverage for admin queries, summaries, reloads, authentication, and reconnection
12
+ - A dedicated CI integration job backed by the repository's Docker Compose stack
13
+ - Configurable `PgBouncerHero.config_path`, resolved from the host application's `Rails.root` by default
14
+ - `PgBouncerHero.reset!` for reloading configuration and connections safely
15
+ - Request-level engine coverage and gem-package validation in CI
16
+
17
+ **Improved:**
18
+ - Use `SHUTDOWN WAIT_FOR_CLIENTS` for graceful shutdowns initiated from the dashboard
19
+ - Reconnect stale or failed pooled connections automatically and close every pool on reset
20
+ - Pin PostgreSQL and PgBouncer development images and wait for container health before testing
21
+ - Let Dependabot maintain Docker image versions alongside gems and GitHub Actions
22
+ - Protect PgBouncer connections from concurrent use while allowing bounded parallel queries
23
+ - Resolve parameterized group and database names consistently in routes and controllers
24
+ - Load Rails engine dependencies explicitly and declare Propshaft as a runtime dependency
25
+ - Cache Appraisal dependencies directly in the Ruby/Rails CI matrix
26
+ - Pin GitHub Actions to their current immutable release commits
27
+ - Add read-only workflow permissions, cancellation of superseded runs, and job timeouts
28
+
1
29
  ## 3.0.0
2
30
 
3
31
  **Breaking Changes:**
data/README.md CHANGED
@@ -14,6 +14,7 @@ A graphical user interface for your PgBouncers.
14
14
 
15
15
  - Ruby >= 3.2
16
16
  - Rails >= 7.2
17
+ - PgBouncer >= 1.23
17
18
  - Propshaft (asset pipeline)
18
19
  - importmap-rails
19
20
 
@@ -69,6 +70,8 @@ pgbouncers:
69
70
  production:
70
71
  primary:
71
72
  url: <%= ENV["PGBOUNCER_PRODUCTION_PRIMARY_DATABASE_URL"] %>
73
+ pool_size: 5
74
+ pool_timeout: 5
72
75
  replica:
73
76
  url: <%= ENV["PGBOUNCER_PRODUCTION_REPLICA_DATABASE_URL"] %>
74
77
  staging:
@@ -76,14 +79,69 @@ pgbouncers:
76
79
  url: <%= ENV["PGBOUNCER_STAGING_PRIMARY_DATABASE_URL"] %>
77
80
  replica:
78
81
  url: <%= ENV["PGBOUNCER_STAGING_REPLICA_DATABASE_URL"] %>
82
+
83
+ # Optional: allow monitoring while blocking all administrative commands
84
+ read_only: true
85
+ ```
86
+
87
+ Environment-specific top-level keys are also supported. PgBouncerHero reads
88
+ `config/pgbouncerhero.yml` from the host application's `Rails.root` by default.
89
+ An initializer can point to a different file:
90
+
91
+ ```ruby
92
+ PgBouncerHero.config_path = Rails.root.join("config/pgbouncerhero.production.yml")
93
+ ```
94
+
95
+ Changing `PgBouncerHero.env` or `config_path` resets cached groups and closes
96
+ their open connections. Call `PgBouncerHero.reset!` after changing a config
97
+ file at runtime, or `PgBouncerHero.disconnect!` when only the connections need
98
+ to be closed. Stale connections reconnect automatically.
99
+
100
+ The PostgreSQL connection timeout defaults to five seconds and can be changed
101
+ with `PGBOUNCERHERO_TIMEOUT`.
102
+
103
+ PgBouncerHero keeps a bounded, lazily created connection pool for each
104
+ configured PgBouncer. The pool defaults to five connections with a five-second
105
+ checkout timeout. Set `PGBOUNCERHERO_POOL_SIZE` and
106
+ `PGBOUNCERHERO_POOL_TIMEOUT` to change the defaults for every PgBouncer, or set
107
+ `pool_size` and `pool_timeout` on an individual database entry as shown above.
108
+ Per-database settings take precedence over environment variables.
109
+
110
+ Code that needs to issue multiple commands on the same PostgreSQL connection
111
+ can lease one explicitly:
112
+
113
+ ```ruby
114
+ database.with_connection do |connection|
115
+ connection.exec("SHOW VERSION")
116
+ connection.exec("SHOW CONFIG")
117
+ end
79
118
  ```
80
119
 
120
+ `database.connection` remains available as a connection-compatible proxy for
121
+ existing integrations. Connections are checked when borrowed, and stale or
122
+ failed connections are discarded and recreated automatically.
123
+
124
+ ### Safe Administration
125
+
126
+ The dashboard shows PgBouncer's current state and provides Reload, Suspend,
127
+ Resume, and graceful shutdown controls. Dashboard shutdown uses
128
+ `SHUTDOWN WAIT_FOR_CLIENTS`: PgBouncer stops accepting new clients and exits
129
+ after existing clients disconnect. The Ruby API keeps `database.shutdown` as
130
+ the immediate command for compatibility and also accepts `:immediate`,
131
+ `:wait_for_clients`, or `:wait_for_servers`. State visibility requires
132
+ PgBouncer 1.19 or newer; graceful shutdown modes require PgBouncer 1.23 or
133
+ newer.
134
+
135
+ Set `read_only: true` at the top level of `config/pgbouncerhero.yml`, or set
136
+ `PGBOUNCERHERO_READ_ONLY=true`, to hide and server-side reject every
137
+ administrative command. The environment variable takes precedence over YAML.
138
+
81
139
  ## Development
82
140
 
83
141
  Start PostgreSQL and PgBouncer with Docker:
84
142
 
85
143
  ```bash
86
- docker compose up -d
144
+ docker compose up --detach --wait
87
145
  ```
88
146
 
89
147
  Run the dummy Rails app:
@@ -98,16 +156,34 @@ Then open http://localhost:3000/pgbouncerhero.
98
156
  Run the test suite:
99
157
 
100
158
  ```bash
101
- bundle exec rake # tests + rubocop + herb
102
- bundle exec appraisal rake test # tests across Rails 7.2, 8.0, 8.1
159
+ bundle exec rake # unit/integration-free tests + rubocop + herb
160
+ bundle exec appraisal rake test # tests across Rails 7.2, 8.0, and 8.1
161
+ bundle exec rake build # build and validate the gem package
162
+ bundle exec rake test:integration # real PgBouncer admin-console tests
103
163
  ```
104
164
 
165
+ The integration task expects PostgreSQL and PgBouncer from the Compose stack.
166
+ Override `PGBOUNCERHERO_INTEGRATION_URL` to test another PgBouncer instance.
167
+ The default is `postgres://pgbouncer:pgbouncer@127.0.0.1:6432/pgbouncer`.
168
+ CI runs this suite against PgBouncer 1.23, 1.24, and 1.25.
169
+
105
170
  Stop Docker when done:
106
171
 
107
172
  ```bash
108
- docker compose down
173
+ docker compose down --volumes
109
174
  ```
110
175
 
176
+ ### Releasing
177
+
178
+ Releases use a scoped RubyGems OIDC API Key Role through GitHub Actions, with no
179
+ long-lived RubyGems API key. The role is restricted to the `pgbouncerhero` gem,
180
+ the `kwent/pgbouncerhero` repository, and the `rubygems.org` audience. Store its
181
+ role token in the `RUBYGEMS_OIDC_ROLE` GitHub Actions secret. Then run the
182
+ **Release** workflow on `master` and enter the version already committed in
183
+ `lib/pgbouncerhero/version.rb`. The workflow verifies the version and test
184
+ suite, exchanges GitHub's OIDC identity for a short-lived credential, publishes
185
+ the gem, pushes the version tag, and creates the matching GitHub release.
186
+
111
187
  ## Contributing
112
188
 
113
189
  1. Fork it
@@ -13,10 +13,11 @@ module PgBouncerHero
13
13
  def set_database
14
14
  @groups = PgBouncerHero.groups
15
15
  if params[:group] && params[:database]
16
- @database = PgBouncerHero.groups[params[:group]].databases.find { |db| db.id.to_s == params[:database].to_s }
16
+ @group = @groups.fetch(params[:group])
17
+ @database = @group.databases.find { |database| database.name.parameterize == params[:database] }
17
18
  else
18
- @group = @groups.first
19
- @database = @groups.first.last.databases.first
19
+ _group_id, @group = @groups.first
20
+ @database = @group.databases.first
20
21
  end
21
22
  end
22
23
  end
@@ -1,5 +1,7 @@
1
1
  module PgBouncerHero
2
2
  class DatabaseController < ApplicationController
3
+ before_action :ensure_writable!, only: %i[reload suspend resume shutdown]
4
+
3
5
  def summary
4
6
  if @database.connection
5
7
  @dbs = @database.summary
@@ -48,6 +50,14 @@ module PgBouncerHero
48
50
  end
49
51
  end
50
52
 
53
+ def state
54
+ if @database.connection
55
+ @state = @database.state
56
+ else
57
+ flash[:error] = "#{@database.name} does not look online."
58
+ end
59
+ end
60
+
51
61
  def reload
52
62
  execute_admin_command(:reload, "reloaded")
53
63
  end
@@ -56,15 +66,25 @@ module PgBouncerHero
56
66
  execute_admin_command(:suspend, "suspended")
57
67
  end
58
68
 
69
+ def resume
70
+ execute_admin_command(:resume, "resumed")
71
+ end
72
+
59
73
  def shutdown
60
- execute_admin_command(:shutdown, "shut down")
74
+ execute_admin_command(:shutdown, "asked to shut down after its clients disconnect", :wait_for_clients)
61
75
  end
62
76
 
63
77
  private
64
78
 
65
- def execute_admin_command(command, past_tense)
79
+ def ensure_writable!
80
+ return unless PgBouncerHero.read_only?
81
+
82
+ render plain: "PgBouncerHero is configured as read-only.", status: :forbidden
83
+ end
84
+
85
+ def execute_admin_command(command, past_tense, *arguments)
66
86
  if @database.connection
67
- @database.public_send(command)
87
+ @database.public_send(command, *arguments)
68
88
  flash[:success] = "#{@database.name} has been #{past_tense}."
69
89
  else
70
90
  flash[:error] = "#{@database.name} does not look online."
@@ -7,11 +7,17 @@
7
7
  <%= link_to "Pools", pools_path(database: database.name.parameterize), class: "px-3 py-1.5 text-sm rounded-md #{is_active('pools') ? 'bg-gray-900 text-white' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'}" %>
8
8
  <%= link_to "Clients", clients_path(database: database.name.parameterize), class: "px-3 py-1.5 text-sm rounded-md #{is_active('clients') ? 'bg-gray-900 text-white' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'}" %>
9
9
  <%= link_to "Configuration", conf_path(database: database.name.parameterize), class: "px-3 py-1.5 text-sm rounded-md #{is_active('conf') ? 'bg-gray-900 text-white' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'}" %>
10
+ <%= link_to "State", state_path(database: database.name.parameterize), class: "px-3 py-1.5 text-sm rounded-md #{is_active('state') ? 'bg-gray-900 text-white' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'}" %>
10
11
  </div>
11
12
  <div class="flex items-center gap-2">
12
- <%= button_to "Reload", reload_path(database: database.name.parameterize), method: :post, class: "px-3 py-1.5 text-sm font-medium rounded-md border border-green-300 text-green-700 hover:bg-green-50", form: { data: { turbo_confirm: "Are you sure?" } } %>
13
- <%= button_to "Suspend", suspend_path(database: database.name.parameterize), method: :post, class: "px-3 py-1.5 text-sm font-medium rounded-md border border-blue-300 text-blue-700 hover:bg-blue-50", form: { data: { turbo_confirm: "Are you sure?" } } %>
14
- <%= button_to "Shutdown", shutdown_path(database: database.name.parameterize), method: :post, class: "px-3 py-1.5 text-sm font-medium rounded-md border border-red-300 text-red-700 hover:bg-red-50", form: { data: { turbo_confirm: "Are you sure?" } } %>
13
+ <% if PgBouncerHero.read_only? %>
14
+ <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>
15
+ <% else %>
16
+ <%= button_to "Reload", reload_path(database: database.name.parameterize), method: :post, class: "px-3 py-1.5 text-sm font-medium rounded-md border border-green-300 text-green-700 hover:bg-green-50", form: { data: { turbo_confirm: "Reload PgBouncer configuration?" } } %>
17
+ <%= button_to "Suspend", suspend_path(database: database.name.parameterize), method: :post, class: "px-3 py-1.5 text-sm font-medium rounded-md border border-blue-300 text-blue-700 hover:bg-blue-50", form: { data: { turbo_confirm: "Suspend all PgBouncer socket processing until Resume is selected?" } } %>
18
+ <%= button_to "Resume", resume_path(database: database.name.parameterize), method: :post, class: "px-3 py-1.5 text-sm font-medium rounded-md border border-green-300 text-green-700 hover:bg-green-50", form: { data: { turbo_confirm: "Resume PgBouncer socket processing?" } } %>
19
+ <%= button_to "Graceful shutdown", shutdown_path(database: database.name.parameterize), method: :post, class: "px-3 py-1.5 text-sm font-medium rounded-md border border-red-300 text-red-700 hover:bg-red-50", form: { data: { turbo_confirm: "Stop accepting new clients and shut down after existing clients disconnect?" } } %>
20
+ <% end %>
15
21
  </div>
16
22
  </div>
17
23
  </nav>
@@ -0,0 +1,11 @@
1
+ <div class="bg-white rounded-lg border border-gray-200 shadow-sm p-4">
2
+ <h4 class="text-base font-semibold text-gray-900 mb-3">State</h4>
3
+ <dl class="grid grid-cols-1 sm:grid-cols-2 gap-4">
4
+ <% state.each do |row| %>
5
+ <div>
6
+ <dt class="text-xs font-medium text-gray-500 uppercase tracking-wider"><%= row["key"].titleize %></dt>
7
+ <dd class="mt-1 text-sm text-gray-900"><%= row["value"] %></dd>
8
+ </div>
9
+ <% end %>
10
+ </dl>
11
+ </div>
@@ -0,0 +1,4 @@
1
+ <%= render partial: "menu", locals: {database: @database} %>
2
+ <% if @state %>
3
+ <%= render partial: "state", locals: {state: @state} %>
4
+ <% end %>
data/config/routes.rb CHANGED
@@ -1,15 +1,23 @@
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
10
16
  get :conf, controller: :database
17
+ get :state, controller: :database
11
18
  post :reload, controller: :database
12
19
  post :suspend, controller: :database
20
+ post :resume, controller: :database
13
21
  post :shutdown, controller: :database
14
22
  end
15
23
  end
@@ -3,6 +3,8 @@ 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
6
8
  # Add more databases
7
9
  # replica:
8
10
  # url: <%%= ENV["PGBOUNCER_PRODUCTION_REPLICA_DATABASE_URL"] %>
@@ -14,3 +16,6 @@ pgbouncers:
14
16
 
15
17
  # Time zone (defaults to app time zone)
16
18
  # time_zone: "Pacific Time (US & Canada)"
19
+
20
+ # Hide and reject Reload, Suspend, Resume, and Shutdown commands
21
+ # 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
@@ -41,6 +71,26 @@ module PgBouncerHero
41
71
 
42
72
  private
43
73
 
74
+ def execute(command)
75
+ with_connection { |raw_connection| raw_connection.exec(command) }
76
+ end
77
+
78
+ def connection_pool
79
+ @pool_monitor.synchronize do
80
+ @connection_pool ||= ConnectionPool.new(size: pool_size, timeout: pool_timeout) { build_connection }
81
+ end
82
+ end
83
+
84
+ def connection_proxy
85
+ @pool_monitor.synchronize do
86
+ @connection_proxy ||= ConnectionPool::Wrapper.new(pool: connection_pool)
87
+ end
88
+ end
89
+
90
+ def build_connection
91
+ connection_model.new(host, port, user, password, dbname)
92
+ end
93
+
44
94
  def connection_model
45
95
  @connection_model ||= begin
46
96
  Class.new(PgBouncerHero::Connection) do
@@ -50,5 +100,21 @@ module PgBouncerHero
50
100
  end
51
101
  end
52
102
  end
103
+
104
+ def positive_integer_setting(config_key, environment_key, default)
105
+ value = config.fetch(config_key, ENV.fetch(environment_key, default))
106
+ parsed = Integer(value)
107
+ raise ArgumentError, "#{config_key} must be greater than zero" unless parsed.positive?
108
+
109
+ parsed
110
+ end
111
+
112
+ def positive_float_setting(config_key, environment_key, default)
113
+ value = config.fetch(config_key, ENV.fetch(environment_key, default))
114
+ parsed = Float(value)
115
+ raise ArgumentError, "#{config_key} must be a finite number greater than zero" unless parsed.positive? && parsed.finite?
116
+
117
+ parsed
118
+ end
53
119
  end
54
120
  end
@@ -1,6 +1,13 @@
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
@@ -12,31 +19,40 @@ module PgBouncerHero
12
19
  end
13
20
  end
14
21
  def databases
15
- connection.exec("SHOW databases")
22
+ execute("SHOW databases")
16
23
  end
17
24
  def stats
18
- connection.exec("SHOW stats")
25
+ execute("SHOW stats")
19
26
  end
20
27
  def lists
21
- connection.exec("SHOW lists")
28
+ execute("SHOW lists")
22
29
  end
23
30
  def pools
24
- connection.exec("SHOW pools")
31
+ execute("SHOW pools")
25
32
  end
26
33
  def clients
27
- connection.exec("SHOW clients")
34
+ execute("SHOW clients")
28
35
  end
29
36
  def conf
30
- connection.exec("SHOW config")
37
+ execute("SHOW config")
38
+ end
39
+ def state
40
+ execute("SHOW state")
31
41
  end
32
42
  def reload
33
- connection.exec("RELOAD")
43
+ execute("RELOAD")
34
44
  end
35
45
  def suspend
36
- connection.exec("SUSPEND")
46
+ execute("SUSPEND")
47
+ end
48
+ def resume
49
+ execute("RESUME")
37
50
  end
38
- def shutdown
39
- connection.exec("SHUTDOWN")
51
+ def shutdown(mode = nil)
52
+ suffix = SHUTDOWN_MODES.fetch(mode)
53
+ execute("SHUTDOWN#{suffix}")
54
+ rescue KeyError
55
+ raise ArgumentError, "unsupported shutdown mode: #{mode.inspect}"
40
56
  end
41
57
  end
42
58
  end
@@ -1,3 +1,3 @@
1
1
  module PgBouncerHero
2
- VERSION = "3.0.0"
2
+ VERSION = "3.1.0"
3
3
  end
data/lib/pgbouncerhero.rb CHANGED
@@ -1,26 +1,44 @@
1
- require "pgbouncerhero/version"
2
-
3
- require "pgbouncerhero/methods/basics"
4
-
5
- require "pgbouncerhero/group"
6
- require "pgbouncerhero/engine" if defined?(Rails)
7
-
8
- require "pgbouncerhero/connection"
9
-
1
+ require "erb"
2
+ require "monitor"
3
+ require "pathname"
4
+ require "yaml"
5
+ require "rails"
10
6
  require "pg"
11
7
  require "importmap-rails"
12
8
  require "turbo-rails"
13
9
  require "stimulus-rails"
14
10
 
11
+ require "pgbouncerhero/version"
12
+ require "pgbouncerhero/methods/basics"
13
+ require "pgbouncerhero/connection"
14
+ require "pgbouncerhero/group"
15
+ require "pgbouncerhero/engine"
16
+
15
17
  module PgBouncerHero
18
+ class ConfigurationError < StandardError; end
19
+
20
+ BOOLEAN_SETTINGS = {
21
+ true => true,
22
+ false => false,
23
+ "1" => true,
24
+ "0" => false,
25
+ "true" => true,
26
+ "false" => false,
27
+ "yes" => true,
28
+ "no" => false,
29
+ "on" => true,
30
+ "off" => false
31
+ }.freeze
32
+ private_constant :BOOLEAN_SETTINGS
33
+
34
+ STATE_MONITOR = Monitor.new
35
+ private_constant :STATE_MONITOR
36
+
16
37
  mattr_accessor :importmap, default: Importmap::Map.new
17
38
 
18
39
  class << self
19
- attr_accessor :env
20
- end
21
- self.env = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
40
+ attr_reader :env
22
41
 
23
- class << self
24
42
  def time_zone=(time_zone)
25
43
  @time_zone = time_zone.is_a?(ActiveSupport::TimeZone) ? time_zone : ActiveSupport::TimeZone[time_zone.to_s]
26
44
  end
@@ -29,38 +47,105 @@ module PgBouncerHero
29
47
  @time_zone || Time.zone
30
48
  end
31
49
 
50
+ def env=(env)
51
+ STATE_MONITOR.synchronize do
52
+ @env = env.to_s
53
+ reset!
54
+ end
55
+ end
56
+
57
+ def config_path
58
+ STATE_MONITOR.synchronize { @config_path || default_config_path }
59
+ end
60
+
61
+ def config_path=(path)
62
+ STATE_MONITOR.synchronize do
63
+ @config_path = path && Pathname(path)
64
+ reset!
65
+ end
66
+ end
67
+
32
68
  def config
33
- @config ||= begin
34
- path = "config/pgbouncerhero.yml"
35
-
36
- config = YAML.safe_load(ERB.new(File.read(path)).result, aliases: true) if File.exist?(path)
37
- config ||= {}
38
-
39
- if config[env]
40
- config[env]
41
- elsif config["pgbouncers"]
42
- config
43
- else
44
- {
45
- "pgbouncers" => {
46
- "default" => {
47
- "primary" => {
48
- "url" => ENV["PGBOUNCERHERO_DATABASE_URL"]
49
- }
50
- }
51
- }
52
- }
69
+ STATE_MONITOR.synchronize { @config ||= selected_config(load_config) }
70
+ end
71
+
72
+ def groups
73
+ STATE_MONITOR.synchronize do
74
+ @groups ||= config.fetch("pgbouncers").to_h do |group_id, _databases|
75
+ [ group_id.parameterize, PgBouncerHero::Group.new(group_id, config.fetch("pgbouncers")) ]
53
76
  end
54
77
  end
55
78
  end
56
79
 
57
- def groups
58
- @groups ||= begin
59
- mapped = config["pgbouncers"].map do |group_id, _hash|
60
- [ group_id.parameterize, PgBouncerHero::Group.new(group_id, config["pgbouncers"]) ]
80
+ def read_only?
81
+ value = ENV.fetch("PGBOUNCERHERO_READ_ONLY") { config.fetch("read_only", false) }
82
+ BOOLEAN_SETTINGS.fetch(value.is_a?(String) ? value.downcase : value)
83
+ rescue KeyError
84
+ raise ConfigurationError, "read_only must be a boolean"
85
+ end
86
+
87
+ def disconnect!
88
+ STATE_MONITOR.synchronize do
89
+ @groups&.each_value do |group|
90
+ group.databases.each(&:disconnect!)
61
91
  end
62
- Hash[mapped]
63
92
  end
64
93
  end
94
+
95
+ def reset!
96
+ STATE_MONITOR.synchronize do
97
+ disconnect!
98
+ @config = nil
99
+ @groups = nil
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ def default_config_path
106
+ (Rails.root || Pathname.pwd).join("config/pgbouncerhero.yml")
107
+ end
108
+
109
+ def load_config
110
+ return {} unless config_path.file?
111
+
112
+ loaded = YAML.safe_load(ERB.new(config_path.read).result, aliases: true)
113
+ return {} if loaded.nil?
114
+ return loaded if loaded.is_a?(Hash)
115
+
116
+ raise ConfigurationError, "#{config_path} must contain a YAML mapping"
117
+ end
118
+
119
+ def selected_config(loaded)
120
+ selected = loaded.fetch(env, loaded)
121
+ unless selected.is_a?(Hash)
122
+ raise ConfigurationError, "configuration for #{env.inspect} must be a YAML mapping"
123
+ end
124
+
125
+ return default_config unless selected.key?("pgbouncers")
126
+
127
+ pgbouncers = selected["pgbouncers"]
128
+ unless pgbouncers.is_a?(Hash) && pgbouncers.any? && pgbouncers.values.all? { |databases| databases.is_a?(Hash) && databases.any? }
129
+ raise ConfigurationError, '"pgbouncers" must contain at least one group with one database'
130
+ end
131
+
132
+ selected
133
+ end
134
+
135
+ def default_config
136
+ {
137
+ "pgbouncers" => {
138
+ "default" => {
139
+ "primary" => {
140
+ "url" => ENV["PGBOUNCERHERO_DATABASE_URL"]
141
+ }
142
+ }
143
+ }
144
+ }
145
+ end
65
146
  end
147
+
148
+ self.env = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
66
149
  end
150
+
151
+ at_exit { PgBouncerHero.disconnect! }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbouncerhero
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Quentin Rousseau
@@ -16,6 +16,9 @@ dependencies:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
18
  version: '7.2'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
@@ -23,6 +26,29 @@ dependencies:
23
26
  - - ">="
24
27
  - !ruby/object:Gem::Version
25
28
  version: '7.2'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: propshaft
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '1.0'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '2'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '1.0'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '2'
26
52
  - !ruby/object:Gem::Dependency
27
53
  name: importmap-rails
28
54
  requirement: !ruby/object:Gem::Requirement
@@ -30,6 +56,9 @@ dependencies:
30
56
  - - ">="
31
57
  - !ruby/object:Gem::Version
32
58
  version: '1.2'
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '3'
33
62
  type: :runtime
34
63
  prerelease: false
35
64
  version_requirements: !ruby/object:Gem::Requirement
@@ -37,6 +66,9 @@ dependencies:
37
66
  - - ">="
38
67
  - !ruby/object:Gem::Version
39
68
  version: '1.2'
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '3'
40
72
  - !ruby/object:Gem::Dependency
41
73
  name: turbo-rails
42
74
  requirement: !ruby/object:Gem::Requirement
@@ -44,6 +76,9 @@ dependencies:
44
76
  - - ">="
45
77
  - !ruby/object:Gem::Version
46
78
  version: '1.0'
79
+ - - "<"
80
+ - !ruby/object:Gem::Version
81
+ version: '3'
47
82
  type: :runtime
48
83
  prerelease: false
49
84
  version_requirements: !ruby/object:Gem::Requirement
@@ -51,6 +86,9 @@ dependencies:
51
86
  - - ">="
52
87
  - !ruby/object:Gem::Version
53
88
  version: '1.0'
89
+ - - "<"
90
+ - !ruby/object:Gem::Version
91
+ version: '3'
54
92
  - !ruby/object:Gem::Dependency
55
93
  name: stimulus-rails
56
94
  requirement: !ruby/object:Gem::Requirement
@@ -58,6 +96,9 @@ dependencies:
58
96
  - - ">="
59
97
  - !ruby/object:Gem::Version
60
98
  version: '1.0'
99
+ - - "<"
100
+ - !ruby/object:Gem::Version
101
+ version: '2'
61
102
  type: :runtime
62
103
  prerelease: false
63
104
  version_requirements: !ruby/object:Gem::Requirement
@@ -65,6 +106,9 @@ dependencies:
65
106
  - - ">="
66
107
  - !ruby/object:Gem::Version
67
108
  version: '1.0'
109
+ - - "<"
110
+ - !ruby/object:Gem::Version
111
+ version: '2'
68
112
  - !ruby/object:Gem::Dependency
69
113
  name: pg
70
114
  requirement: !ruby/object:Gem::Requirement
@@ -72,6 +116,9 @@ dependencies:
72
116
  - - ">="
73
117
  - !ruby/object:Gem::Version
74
118
  version: '1.2'
119
+ - - "<"
120
+ - !ruby/object:Gem::Version
121
+ version: '2'
75
122
  type: :runtime
76
123
  prerelease: false
77
124
  version_requirements: !ruby/object:Gem::Requirement
@@ -79,6 +126,29 @@ dependencies:
79
126
  - - ">="
80
127
  - !ruby/object:Gem::Version
81
128
  version: '1.2'
129
+ - - "<"
130
+ - !ruby/object:Gem::Version
131
+ version: '2'
132
+ - !ruby/object:Gem::Dependency
133
+ name: connection_pool
134
+ requirement: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '3.0'
139
+ - - "<"
140
+ - !ruby/object:Gem::Version
141
+ version: '4'
142
+ type: :runtime
143
+ prerelease: false
144
+ version_requirements: !ruby/object:Gem::Requirement
145
+ requirements:
146
+ - - ">="
147
+ - !ruby/object:Gem::Version
148
+ version: '3.0'
149
+ - - "<"
150
+ - !ruby/object:Gem::Version
151
+ version: '4'
82
152
  - !ruby/object:Gem::Dependency
83
153
  name: rake
84
154
  requirement: !ruby/object:Gem::Requirement
@@ -113,14 +183,14 @@ dependencies:
113
183
  requirements:
114
184
  - - "~>"
115
185
  - !ruby/object:Gem::Version
116
- version: '5.0'
186
+ version: '6.0'
117
187
  type: :development
118
188
  prerelease: false
119
189
  version_requirements: !ruby/object:Gem::Requirement
120
190
  requirements:
121
191
  - - "~>"
122
192
  - !ruby/object:Gem::Version
123
- version: '5.0'
193
+ version: '6.0'
124
194
  - !ruby/object:Gem::Dependency
125
195
  name: appraisal
126
196
  requirement: !ruby/object:Gem::Requirement
@@ -203,11 +273,13 @@ files:
203
273
  - app/views/pg_bouncer_hero/database/_databases.html.erb
204
274
  - app/views/pg_bouncer_hero/database/_menu.html.erb
205
275
  - app/views/pg_bouncer_hero/database/_pools.html.erb
276
+ - app/views/pg_bouncer_hero/database/_state.html.erb
206
277
  - app/views/pg_bouncer_hero/database/_stats.html.erb
207
278
  - app/views/pg_bouncer_hero/database/clients.html.erb
208
279
  - app/views/pg_bouncer_hero/database/conf.html.erb
209
280
  - app/views/pg_bouncer_hero/database/databases.html.erb
210
281
  - app/views/pg_bouncer_hero/database/pools.html.erb
282
+ - app/views/pg_bouncer_hero/database/state.html.erb
211
283
  - app/views/pg_bouncer_hero/database/stats.html.erb
212
284
  - app/views/pg_bouncer_hero/database/summary.html.erb
213
285
  - app/views/pg_bouncer_hero/home/_card.html.erb
@@ -233,6 +305,7 @@ licenses:
233
305
  metadata:
234
306
  source_code_uri: https://github.com/kwent/pgbouncerhero
235
307
  changelog_uri: https://github.com/kwent/pgbouncerhero/blob/master/CHANGELOG.md
308
+ documentation_uri: https://github.com/kwent/pgbouncerhero#readme
236
309
  bug_tracker_uri: https://github.com/kwent/pgbouncerhero/issues
237
310
  rubygems_mfa_required: 'true'
238
311
  rdoc_options: []