pgbouncerhero 2.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/README.md +155 -58
  4. data/app/assets/builds/pgbouncerhero/application.css +2 -0
  5. data/app/assets/stylesheets/pgbouncerhero/application.css +1 -0
  6. data/app/controllers/pg_bouncer_hero/application_controller.rb +6 -9
  7. data/app/controllers/pg_bouncer_hero/database_controller.rb +39 -13
  8. data/app/controllers/pg_bouncer_hero/home_controller.rb +0 -2
  9. data/app/helpers/pg_bouncer_hero/application_helper.rb +19 -13
  10. data/app/javascript/pgbouncerhero/application.js +6 -0
  11. data/app/javascript/pgbouncerhero/controllers/polling_controller.js +27 -0
  12. data/app/views/layouts/pg_bouncer_hero/application.html.erb +19 -22
  13. data/app/views/pg_bouncer_hero/database/_actions.html.erb +9 -9
  14. data/app/views/pg_bouncer_hero/database/_clients.html.erb +21 -23
  15. data/app/views/pg_bouncer_hero/database/_conf.html.erb +21 -23
  16. data/app/views/pg_bouncer_hero/database/_databases.html.erb +21 -23
  17. data/app/views/pg_bouncer_hero/database/_menu.html.erb +20 -18
  18. data/app/views/pg_bouncer_hero/database/_pools.html.erb +21 -23
  19. data/app/views/pg_bouncer_hero/database/_state.html.erb +11 -0
  20. data/app/views/pg_bouncer_hero/database/_stats.html.erb +27 -33
  21. data/app/views/pg_bouncer_hero/database/state.html.erb +4 -0
  22. data/app/views/pg_bouncer_hero/database/summary.html.erb +3 -0
  23. data/app/views/pg_bouncer_hero/home/_card.html.erb +9 -22
  24. data/app/views/pg_bouncer_hero/home/_card_content.html.erb +33 -33
  25. data/app/views/pg_bouncer_hero/home/_card_loading_content.html.erb +5 -5
  26. data/app/views/pg_bouncer_hero/home/index.html.erb +13 -37
  27. data/app/views/shared/_alert.html.erb +2 -5
  28. data/config/importmap.rb +5 -0
  29. data/config/routes.rb +10 -2
  30. data/lib/generators/pgbouncerhero/templates/config.yml +13 -8
  31. data/lib/pgbouncerhero/connection.rb +53 -16
  32. data/lib/pgbouncerhero/database.rb +69 -4
  33. data/lib/pgbouncerhero/engine.rb +13 -10
  34. data/lib/pgbouncerhero/group.rb +0 -2
  35. data/lib/pgbouncerhero/methods/basics.rb +28 -12
  36. data/lib/pgbouncerhero/version.rb +1 -1
  37. data/lib/pgbouncerhero.rb +124 -41
  38. metadata +187 -46
  39. data/.gitignore +0 -19
  40. data/Gemfile +0 -4
  41. data/app/assets/images/pgbouncerhero/short-paragraph.png +0 -0
  42. data/app/assets/javascripts/pgbouncerhero/application.js +0 -3
  43. data/app/assets/stylesheets/pgbouncerhero/application.css.scss +0 -15
  44. data/app/views/pg_bouncer_hero/database/summary.js.erb +0 -5
  45. data/doc/screenshot-1.png +0 -0
  46. data/doc/screenshot-2.png +0 -0
  47. data/doc/screenshot-3.png +0 -0
  48. data/pgbouncerhero.gemspec +0 -32
@@ -1,6 +1,5 @@
1
1
  module PgBouncerHero
2
2
  class Connection
3
-
4
3
  def initialize(host, port, user, password, dbname)
5
4
  @host = host
6
5
  @port = port
@@ -11,22 +10,60 @@ module PgBouncerHero
11
10
  end
12
11
 
13
12
  def connection
14
- @connection ||= begin
15
- begin
16
- PG.connect(
17
- host: @host,
18
- port: @port,
19
- user: @user,
20
- password: @password,
21
- dbname: @dbname,
22
- connect_timeout: @timeout
23
- )
24
- rescue Exception => e
25
- Rails.logger.error("[PGBouncerHero] Host:#{@host} | Database Name:#{@dbname} | Timeout: #{@timeout}s => #{e}")
26
- nil
27
- end
28
- 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
29
40
  end
30
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
67
+ end
31
68
  end
32
69
  end
@@ -1,15 +1,23 @@
1
+ require "connection_pool"
2
+ require "monitor"
3
+
1
4
  module PgBouncerHero
2
5
  class Database
3
-
4
6
  include Methods::Basics
5
7
 
6
- 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
7
12
 
8
13
  def initialize(group, id, config)
9
14
  @id = id
10
15
  @config = config || {}
11
- @url = URI.parse(config["url"])
16
+ @url = URI.parse(@config["url"].to_s)
12
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
13
21
  end
14
22
 
15
23
  def name
@@ -17,7 +25,28 @@ module PgBouncerHero
17
25
  end
18
26
 
19
27
  def connection
20
- @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!)
21
50
  end
22
51
 
23
52
  def host
@@ -42,6 +71,26 @@ module PgBouncerHero
42
71
 
43
72
  private
44
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
+
45
94
  def connection_model
46
95
  @connection_model ||= begin
47
96
  Class.new(PgBouncerHero::Connection) do
@@ -51,5 +100,21 @@ module PgBouncerHero
51
100
  end
52
101
  end
53
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
54
119
  end
55
120
  end
@@ -2,18 +2,21 @@ module PgBouncerHero
2
2
  class Engine < ::Rails::Engine
3
3
  isolate_namespace PgBouncerHero
4
4
 
5
- initializer "pgbouncerhero", group: :all do |app|
6
- if defined?(Sprockets) && Sprockets::VERSION >= "4"
7
- app.config.assets.precompile << "pgbouncerhero/application.js"
8
- app.config.assets.precompile << "pgbouncerhero/application.css"
9
- app.config.assets.precompile << "pgbouncerhero/short-paragraph.png"
10
- else
11
- # use a proc instead of a string
12
- app.config.assets.precompile << proc { |path| path == "pgbouncerhero/application.js" }
13
- app.config.assets.precompile << proc { |path| path == "pgbouncerhero/application.css" }
14
- app.config.assets.precompile << proc { |path| path == "pgbouncerhero/short-paragraph.png" }
5
+ initializer "pgbouncerhero.assets" do |app|
6
+ app.config.assets.paths << root.join("app/assets/builds")
7
+ app.config.assets.paths << root.join("app/javascript")
8
+ end
9
+
10
+ initializer "pgbouncerhero.importmap", before: "importmap" do |app|
11
+ PgBouncerHero.importmap.draw root.join("config/importmap.rb")
12
+ PgBouncerHero.importmap.cache_sweeper watches: root.join("app/javascript")
13
+
14
+ ActiveSupport.on_load(:action_controller_base) do
15
+ before_action { PgBouncerHero.importmap.cache_sweeper.execute_if_updated }
15
16
  end
17
+ end
16
18
 
19
+ initializer "pgbouncerhero.configuration" do
17
20
  PgBouncerHero.time_zone = PgBouncerHero.config["time_zone"] if PgBouncerHero.config["time_zone"]
18
21
  end
19
22
  end
@@ -2,7 +2,6 @@ require "pgbouncerhero/database"
2
2
 
3
3
  module PgBouncerHero
4
4
  class Group
5
-
6
5
  attr_reader :id, :config, :databases
7
6
 
8
7
  def initialize(id, config)
@@ -16,6 +15,5 @@ module PgBouncerHero
16
15
  def name
17
16
  @name ||= id.to_s
18
17
  end
19
-
20
18
  end
21
19
  end
@@ -1,42 +1,58 @@
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
8
15
  l = l.as_json
9
- d = d.as_json.reject { |a| a['name'] == 'pgbouncer' }
10
- l.push({databases_details: d})
16
+ d = d.as_json.reject { |a| a["name"] == "pgbouncer" }
17
+ l.push({ databases_details: d })
11
18
  l
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 = "2.0.0"
2
+ VERSION = "3.1.0"
3
3
  end
data/lib/pgbouncerhero.rb CHANGED
@@ -1,27 +1,43 @@
1
- require "pgbouncerhero/version"
1
+ require "erb"
2
+ require "monitor"
3
+ require "pathname"
4
+ require "yaml"
5
+ require "rails"
6
+ require "pg"
7
+ require "importmap-rails"
8
+ require "turbo-rails"
9
+ require "stimulus-rails"
2
10
 
3
- # methods
11
+ require "pgbouncerhero/version"
4
12
  require "pgbouncerhero/methods/basics"
5
-
13
+ require "pgbouncerhero/connection"
6
14
  require "pgbouncerhero/group"
7
- require "pgbouncerhero/engine" if defined?(Rails)
15
+ require "pgbouncerhero/engine"
8
16
 
9
- # models
10
- require "pgbouncerhero/connection"
17
+ module PgBouncerHero
18
+ class ConfigurationError < StandardError; end
11
19
 
12
- # others
13
- require "jquery-rails"
14
- require "semantic-ui-sass"
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
15
33
 
16
- module PgBouncerHero
17
- # settings
18
- class << self
19
- attr_accessor :env
20
- end
21
- self.env = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
34
+ STATE_MONITOR = Monitor.new
35
+ private_constant :STATE_MONITOR
36
+
37
+ mattr_accessor :importmap, default: Importmap::Map.new
22
38
 
23
39
  class << self
24
- extend Forwardable
40
+ attr_reader :env
25
41
 
26
42
  def time_zone=(time_zone)
27
43
  @time_zone = time_zone.is_a?(ActiveSupport::TimeZone) ? time_zone : ActiveSupport::TimeZone[time_zone.to_s]
@@ -31,38 +47,105 @@ module PgBouncerHero
31
47
  @time_zone || Time.zone
32
48
  end
33
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
+
34
68
  def config
35
- Thread.current[:PgBouncerHero_config] ||= begin
36
- path = "config/pgbouncerhero.yml"
37
-
38
- config = YAML.load(ERB.new(File.read(path)).result) if File.exist?(path)
39
- config ||= {}
40
-
41
- if config[env]
42
- config[env]
43
- elsif config["pgbouncers"] # preferred format
44
- config
45
- else
46
- {
47
- "pgbouncers" => {
48
- "default" => {
49
- "primary" => {
50
- "url" => ENV["PGBOUNCERHERO_DATABASE_URL"]
51
- }
52
- }
53
- }
54
- }
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")) ]
55
76
  end
56
77
  end
57
78
  end
58
79
 
59
- def groups
60
- @groups ||= begin
61
- mapped = config['pgbouncers'].map do |group_id, hash|
62
- [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!)
63
91
  end
64
- Hash[mapped]
65
92
  end
66
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
67
146
  end
147
+
148
+ self.env = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
68
149
  end
150
+
151
+ at_exit { PgBouncerHero.disconnect! }