pghero 2.4.0 → 2.6.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of pghero might be problematic. Click here for more details.

@@ -59,7 +59,7 @@ function initSlider() {
59
59
  html = "Now";
60
60
  }
61
61
  } else {
62
- html = months[time.getMonth()] + " " + time.getDate() + ", " + pad(time.getHours()) + ":" + pad(time.getMinutes());
62
+ html = time.getDate() + " " + months[time.getMonth()] + " " + pad(time.getHours()) + ":" + pad(time.getMinutes());
63
63
  }
64
64
  $(selector).html(html);
65
65
  }
@@ -13,6 +13,11 @@ module PgHero
13
13
  before_action :ensure_query_stats, only: [:queries]
14
14
 
15
15
  if PgHero.config["override_csp"]
16
+ # note: this does not take into account asset hosts
17
+ # which can be a string with %d or a proc
18
+ # https://api.rubyonrails.org/classes/ActionView/Helpers/AssetUrlHelper.html
19
+ # users should set CSP manually if needed
20
+ # see https://github.com/ankane/pghero/issues/297
16
21
  after_action do
17
22
  response.headers["Content-Security-Policy"] = "default-src 'self' 'unsafe-inline'"
18
23
  end
@@ -198,6 +203,11 @@ module PgHero
198
203
  "1 week" => {duration: 1.week, period: 30.minutes},
199
204
  "2 weeks" => {duration: 2.weeks, period: 1.hours}
200
205
  }
206
+ if @database.system_stats_provider == :azure
207
+ # doesn't support 10, just 5 and 15
208
+ @periods["1 day"][:period] = 15.minutes
209
+ end
210
+
201
211
  @duration = (params[:duration] || 1.hour).to_i
202
212
  @period = (params[:period] || 60.seconds).to_i
203
213
 
@@ -209,22 +219,36 @@ module PgHero
209
219
  end
210
220
 
211
221
  def cpu_usage
212
- render json: [{name: "CPU", data: @database.cpu_usage(system_params).map { |k, v| [k, v.round] }, library: chart_library_options}]
222
+ render json: [{name: "CPU", data: @database.cpu_usage(**system_params).map { |k, v| [k, v ? v.round : v] }, library: chart_library_options}]
213
223
  end
214
224
 
215
225
  def connection_stats
216
- render json: [{name: "Connections", data: @database.connection_stats(system_params), library: chart_library_options}]
226
+ render json: [{name: "Connections", data: @database.connection_stats(**system_params), library: chart_library_options}]
217
227
  end
218
228
 
219
229
  def replication_lag_stats
220
- render json: [{name: "Lag", data: @database.replication_lag_stats(system_params), library: chart_library_options}]
230
+ render json: [{name: "Lag", data: @database.replication_lag_stats(**system_params), library: chart_library_options}]
221
231
  end
222
232
 
223
233
  def load_stats
224
- render json: [
225
- {name: "Read IOPS", data: @database.read_iops_stats(system_params).map { |k, v| [k, v.round] }, library: chart_library_options},
226
- {name: "Write IOPS", data: @database.write_iops_stats(system_params).map { |k, v| [k, v.round] }, library: chart_library_options}
227
- ]
234
+ stats =
235
+ case @database.system_stats_provider
236
+ when :azure
237
+ [
238
+ {name: "IO Consumption", data: @database.azure_stats("io_consumption_percent", **system_params), library: chart_library_options}
239
+ ]
240
+ when :gcp
241
+ [
242
+ {name: "Read Ops", data: @database.read_iops_stats(**system_params).map { |k, v| [k, v ? v.round : v] }, library: chart_library_options},
243
+ {name: "Write Ops", data: @database.write_iops_stats(**system_params).map { |k, v| [k, v ? v.round : v] }, library: chart_library_options}
244
+ ]
245
+ else
246
+ [
247
+ {name: "Read IOPS", data: @database.read_iops_stats(**system_params).map { |k, v| [k, v ? v.round : v] }, library: chart_library_options},
248
+ {name: "Write IOPS", data: @database.write_iops_stats(**system_params).map { |k, v| [k, v ? v.round : v] }, library: chart_library_options}
249
+ ]
250
+ end
251
+ render json: stats
228
252
  end
229
253
 
230
254
  def free_space_stats
@@ -270,17 +294,48 @@ module PgHero
270
294
 
271
295
  def connections
272
296
  @title = "Connections"
273
- @connection_sources = @database.connection_sources
274
- @total_connections = @connection_sources.sum { |cs| cs[:total_connections] }
297
+ connections = @database.connections
298
+
299
+ @total_connections = connections.count
300
+ @connection_sources = group_connections(connections, [:database, :user, :source, :ip])
301
+ @connections_by_database = group_connections_by_key(connections, :database)
302
+ @connections_by_user = group_connections_by_key(connections, :user)
303
+
304
+ if params[:security] && @database.server_version_num >= 90500
305
+ connections.each do |connection|
306
+ connection[:ssl_status] =
307
+ if connection[:ssl]
308
+ # no way to tell if client used verify-full
309
+ # so connection may not be actually secure
310
+ "SSL"
311
+ else
312
+ # variety of reasons for no SSL
313
+ if !connection[:database].present?
314
+ "Internal Process"
315
+ elsif !connection[:ip]
316
+ if connection[:state]
317
+ "Socket"
318
+ else
319
+ # tcp or socket, don't have permission to tell
320
+ "No SSL"
321
+ end
322
+ else
323
+ # tcp
324
+ # could separate out localhost since this should be safe
325
+ "No SSL"
326
+ end
327
+ end
328
+ end
275
329
 
276
- @connections_by_database = group_connections(@connection_sources, :database)
277
- @connections_by_user = group_connections(@connection_sources, :user)
330
+ @connections_by_ssl_status = group_connections_by_key(connections, :ssl_status)
331
+ end
278
332
  end
279
333
 
280
334
  def maintenance
281
335
  @title = "Maintenance"
282
336
  @maintenance_info = @database.maintenance_info
283
337
  @time_zone = PgHero.time_zone
338
+ @show_dead_rows = params[:dead_rows]
284
339
  end
285
340
 
286
341
  def kill
@@ -363,7 +418,8 @@ module PgHero
363
418
  def system_params
364
419
  {
365
420
  duration: params[:duration],
366
- period: params[:period]
421
+ period: params[:period],
422
+ series: true
367
423
  }.delete_if { |_, v| v.nil? }
368
424
  end
369
425
 
@@ -376,18 +432,22 @@ module PgHero
376
432
  @show_details = @historical_query_stats_enabled && @database.supports_query_hash?
377
433
  end
378
434
 
379
- def group_connections(connection_sources, key)
380
- top_connections = Hash.new(0)
381
- connection_sources.each do |source|
382
- top_connections[source[key]] += source[:total_connections]
383
- end
384
- top_connections.sort_by { |k, v| [-v, k] }
435
+ def group_connections(connections, keys)
436
+ connections
437
+ .group_by { |conn| conn.slice(*keys) }
438
+ .map { |k, v| k.merge(total_connections: v.count) }
439
+ .sort_by { |v| [-v[:total_connections]] + keys.map { |k| v[k].to_s } }
440
+ end
441
+
442
+ def group_connections_by_key(connections, key)
443
+ group_connections(connections, [key]).map { |v| [v[key], v[:total_connections]] }.to_h
385
444
  end
386
445
 
387
446
  def check_api
388
447
  render_text "No support for Rails API. See https://github.com/pghero/pghero for a standalone app." if Rails.application.config.try(:api_only)
389
448
  end
390
449
 
450
+ # TODO return error status code
391
451
  def render_text(message)
392
452
  render plain: message
393
453
  end
@@ -15,5 +15,16 @@ module PgHero
15
15
  def pghero_js_var(name, value)
16
16
  "var #{name} = #{json_escape(value.to_json(root: false))};".html_safe
17
17
  end
18
+
19
+ def pghero_remove_index(query)
20
+ if query[:columns]
21
+ columns = query[:columns].map(&:to_sym)
22
+ columns = columns.first if columns.size == 1
23
+ end
24
+ ret = String.new("remove_index #{query[:table].to_sym.inspect}")
25
+ ret << ", name: #{(query[:name] || query[:index]).to_s.inspect}"
26
+ ret << ", column: #{columns.inspect}" if columns
27
+ ret
28
+ end
18
29
  end
19
30
  end
@@ -30,7 +30,9 @@
30
30
  <% end %>
31
31
  </td>
32
32
  <td class="text-right">
33
- <%= button_to "Explain", explain_path, params: {query: query[:query]}, form: {target: "_blank"}, class: "btn btn-info" %>
33
+ <% unless @database.filter_data %>
34
+ <%= button_to "Explain", explain_path, params: {query: query[:query]}, form: {target: "_blank"}, class: "btn btn-info" %>
35
+ <% end %>
34
36
  <%= button_to "Kill", kill_path(pid: query[:pid]), class: "btn btn-danger" %>
35
37
  </td>
36
38
  </tr>
@@ -18,6 +18,15 @@
18
18
  new Chartkick.PieChart("chart-2", <%= json_escape(@connections_by_user.to_json).html_safe %>);
19
19
  </script>
20
20
 
21
+ <% if @connections_by_ssl_status %>
22
+ <h3>By Security</h3>
23
+
24
+ <div id="chart-3" class="chart" style="height: 260px; line-height: 260px; margin-bottom: 20px;">Loading...</div>
25
+ <script>
26
+ new Chartkick.PieChart("chart-3", <%= json_escape(@connections_by_ssl_status.to_json).html_safe %>);
27
+ </script>
28
+ <% end %>
29
+
21
30
  <%= render partial: "connections_table", locals: {connection_sources: @connection_sources} %>
22
31
  <% end %>
23
32
  </div>
@@ -387,7 +387,7 @@
387
387
  <pre>rails generate migration remove_unneeded_indexes</pre>
388
388
  <p>And paste</p>
389
389
  <pre style="overflow: scroll; white-space: pre; word-break: normal;"><% @duplicate_indexes.each do |query| %>
390
- remove_index <%= query[:unneeded_index][:table].to_sym.inspect %>, name: <%= query[:unneeded_index][:name].to_s.inspect %><% end %></pre>
390
+ <%= pghero_remove_index(query[:unneeded_index]) %><% end %></pre>
391
391
  </div>
392
392
 
393
393
  <table class="table duplicate-indexes">
@@ -491,7 +491,7 @@ pg_stat_statements.track = all
491
491
  <pre>rails generate migration remove_unused_indexes</pre>
492
492
  <p>And paste</p>
493
493
  <pre style="overflow: scroll; white-space: pre; word-break: normal;"><% @unused_indexes.each do |query| %>
494
- remove_index <%= query[:table].to_sym.inspect %>, name: <%= query[:index].to_s.inspect %><% end %></pre>
494
+ <%= pghero_remove_index(query)%><% end %></pre>
495
495
  </div>
496
496
 
497
497
  <table class="table">
@@ -7,6 +7,9 @@
7
7
  <th>Table</th>
8
8
  <th style="width: 20%;">Last Vacuum</th>
9
9
  <th style="width: 20%;">Last Analyze</th>
10
+ <% if @show_dead_rows %>
11
+ <th style="width: 20%;">Dead Rows</th>
12
+ <% end %>
10
13
  </tr>
11
14
  </thead>
12
15
  <tbody>
@@ -21,7 +24,7 @@
21
24
  <td>
22
25
  <% time = [table[:last_autovacuum], table[:last_vacuum]].compact.max %>
23
26
  <% if time %>
24
- <%= time.in_time_zone(@time_zone).strftime("%-m/%-e %l:%M %P") %>
27
+ <%= l time.in_time_zone(@time_zone), format: :short %>
25
28
  <% else %>
26
29
  <span class="text-muted">Unknown</span>
27
30
  <% end %>
@@ -29,11 +32,22 @@
29
32
  <td>
30
33
  <% time = [table[:last_autoanalyze], table[:last_analyze]].compact.max %>
31
34
  <% if time %>
32
- <%= time.in_time_zone(@time_zone).strftime("%-m/%-e %l:%M %P") %>
35
+ <%= l time.in_time_zone(@time_zone), format: :short %>
33
36
  <% else %>
34
37
  <span class="text-muted">Unknown</span>
35
38
  <% end %>
36
39
  </td>
40
+ <% if @show_dead_rows %>
41
+ <td>
42
+ <% if table[:live_rows] != 0 %>
43
+ <%# use live rows only for denominator to make it easier to compare with autovacuum_vacuum_scale_factor %>
44
+ <%# it's not a true percentage, since it can go above 100% %>
45
+ <%= (100.0 * table[:dead_rows] / table[:live_rows]).round %>%
46
+ <% else %>
47
+ <span class="text-muted">Unknown</span>
48
+ <% end %>
49
+ </td>
50
+ <% end %>
37
51
  </tr>
38
52
  <% end %>
39
53
  </tbody>
@@ -33,7 +33,7 @@
33
33
  <pre>rails generate migration remove_unused_indexes</pre>
34
34
  <p>And paste</p>
35
35
  <pre style="overflow: scroll; white-space: pre; word-break: normal;"><% @unused_indexes.sort_by { |q| [-q[:size_bytes], q[:index]] }.each do |query| %>
36
- remove_index <%= query[:table].to_sym.inspect %>, name: <%= query[:index].to_s.inspect %><% end %></pre>
36
+ <%= pghero_remove_index(query) %><% end %></pre>
37
37
  </div>
38
38
  <% end %>
39
39
 
@@ -18,7 +18,8 @@
18
18
  </tbody>
19
19
  </table>
20
20
 
21
- <p>Check out <%= link_to "PgTune", "https://pgtune.leopard.in.ua/", target: "_blank" %> for recommendations. DB version is <%= @database.server_version.split(" ").first.split(".").first(2).join(".") %>.</p>
21
+ <% version_parts = @database.server_version.split(" ").first.split(".") %>
22
+ <p>Check out <%= link_to "PgTune", "https://pgtune.leopard.in.ua/", target: "_blank" %> for recommendations. DB version is <%= version_parts[0].to_i >= 10 ? version_parts[0] : version_parts.first(2).join(".") %>.</p>
22
23
  </div>
23
24
 
24
25
  <% if @autovacuum_settings %>
@@ -3,6 +3,11 @@ databases:
3
3
  # Database URL (defaults to app database)
4
4
  # url: <%%= ENV["DATABASE_URL"] %>
5
5
 
6
+ # System stats
7
+ # aws_db_instance_identifier: my-instance
8
+ # gcp_database_id: my-project:my-instance
9
+ # azure_resource_id: my-resource-id
10
+
6
11
  # Add more databases
7
12
  # other:
8
13
  # url: <%%= ENV["OTHER_DATABASE_URL"] %>
@@ -27,13 +32,15 @@ databases:
27
32
 
28
33
  # Basic authentication
29
34
  # username: admin
30
- # password: secret
35
+ # password: <%%= ENV["PGHERO_PASSWORD"] %>
31
36
 
32
37
  # Stats database URL (defaults to app database)
33
38
  # stats_database_url: <%%= ENV["PGHERO_STATS_DATABASE_URL"] %>
34
39
 
35
40
  # AWS configuration (defaults to app AWS config)
36
- # also need aws_db_instance_identifier with each database
37
- # aws_access_key_id: ...
38
- # aws_secret_access_key: ...
41
+ # aws_access_key_id: <%%= ENV["AWS_ACCESS_KEY_ID"] %>
42
+ # aws_secret_access_key: <%%= ENV["AWS_SECRET_ACCESS_KEY"] %>
39
43
  # aws_region: us-east-1
44
+
45
+ # Filter data from queries (experimental)
46
+ # filter_data: true
@@ -34,24 +34,27 @@ module PgHero
34
34
  class Error < StandardError; end
35
35
  class NotEnabled < Error; end
36
36
 
37
+ MUTEX = Mutex.new
38
+
37
39
  # settings
38
40
  class << self
39
- attr_accessor :long_running_query_sec, :slow_query_ms, :slow_query_calls, :explain_timeout_sec, :total_connections_threshold, :cache_hit_rate_threshold, :env, :show_migrations, :config_path
41
+ attr_accessor :long_running_query_sec, :slow_query_ms, :slow_query_calls, :explain_timeout_sec, :total_connections_threshold, :cache_hit_rate_threshold, :env, :show_migrations, :config_path, :filter_data
40
42
  end
41
43
  self.long_running_query_sec = (ENV["PGHERO_LONG_RUNNING_QUERY_SEC"] || 60).to_i
42
44
  self.slow_query_ms = (ENV["PGHERO_SLOW_QUERY_MS"] || 20).to_i
43
45
  self.slow_query_calls = (ENV["PGHERO_SLOW_QUERY_CALLS"] || 100).to_i
44
- self.explain_timeout_sec = (ENV["PGHERO_EXPLAIN_TIMEOUT_SEC"] || 10).to_i
46
+ self.explain_timeout_sec = (ENV["PGHERO_EXPLAIN_TIMEOUT_SEC"] || 10).to_f
45
47
  self.total_connections_threshold = (ENV["PGHERO_TOTAL_CONNECTIONS_THRESHOLD"] || 500).to_i
46
48
  self.cache_hit_rate_threshold = 99
47
49
  self.env = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
48
50
  self.show_migrations = true
49
51
  self.config_path = ENV["PGHERO_CONFIG_PATH"] || "config/pghero.yml"
52
+ self.filter_data = ENV["PGHERO_FILTER_DATA"].to_s.size > 0
50
53
 
51
54
  class << self
52
55
  extend Forwardable
53
56
  def_delegators :primary_database, :access_key_id, :analyze, :analyze_tables, :autoindex, :autovacuum_danger,
54
- :best_index, :blocked_queries, :connection_sources, :connection_states, :connection_stats,
57
+ :best_index, :blocked_queries, :connections, :connection_sources, :connection_states, :connection_stats,
55
58
  :cpu_usage, :create_user, :database_size, :db_instance_identifier, :disable_query_stats, :drop_user,
56
59
  :duplicate_indexes, :enable_query_stats, :explain, :historical_query_stats_enabled?, :index_caching,
57
60
  :index_hit_rate, :index_usage, :indexes, :invalid_constraints, :invalid_indexes, :kill, :kill_all, :kill_long_running_queries,
@@ -116,11 +119,18 @@ module PgHero
116
119
 
117
120
  if databases.empty?
118
121
  databases["primary"] = {
119
- "url" => ENV["PGHERO_DATABASE_URL"] || ActiveRecord::Base.connection_config,
120
- "db_instance_identifier" => ENV["PGHERO_DB_INSTANCE_IDENTIFIER"]
122
+ "url" => ENV["PGHERO_DATABASE_URL"] || ActiveRecord::Base.connection_config
121
123
  }
122
124
  end
123
125
 
126
+ if databases.size == 1
127
+ databases.values.first.merge!(
128
+ "db_instance_identifier" => ENV["PGHERO_DB_INSTANCE_IDENTIFIER"],
129
+ "gcp_database_id" => ENV["PGHERO_GCP_DATABASE_ID"],
130
+ "azure_resource_id" => ENV["PGHERO_AZURE_RESOURCE_ID"]
131
+ )
132
+ end
133
+
124
134
  {
125
135
  "databases" => databases
126
136
  }
@@ -128,14 +138,20 @@ module PgHero
128
138
  end
129
139
  end
130
140
 
141
+ # ensure we only have one copy of databases
142
+ # so there's only one connection pool per database
131
143
  def databases
132
- @databases ||= begin
133
- Hash[
134
- config["databases"].map do |id, c|
135
- [id.to_sym, PgHero::Database.new(id, c)]
136
- end
137
- ]
144
+ unless defined?(@databases)
145
+ # only use mutex on initialization
146
+ MUTEX.synchronize do
147
+ # return if another process initialized while we were waiting
148
+ return @databases if defined?(@databases)
149
+
150
+ @databases = config["databases"].map { |id, c| [id.to_sym, Database.new(id, c)] }.to_h
151
+ end
138
152
  end
153
+
154
+ @databases
139
155
  end
140
156
 
141
157
  def primary_database
@@ -23,6 +23,11 @@ module PgHero
23
23
  def initialize(id, config)
24
24
  @id = id
25
25
  @config = config || {}
26
+
27
+ # preload model to ensure only one connection pool
28
+ # this doesn't actually start any connections
29
+ @adapter_checked = false
30
+ @connection_model = build_connection_model
26
31
  end
27
32
 
28
33
  def name
@@ -50,15 +55,16 @@ module PgHero
50
55
  end
51
56
 
52
57
  def explain_timeout_sec
53
- (config["explain_timeout_sec"] || PgHero.config["explain_timeout_sec"] || PgHero.explain_timeout_sec).to_i
58
+ (config["explain_timeout_sec"] || PgHero.config["explain_timeout_sec"] || PgHero.explain_timeout_sec).to_f
54
59
  end
55
60
 
56
61
  def long_running_query_sec
57
62
  (config["long_running_query_sec"] || PgHero.config["long_running_query_sec"] || PgHero.long_running_query_sec).to_i
58
63
  end
59
64
 
65
+ # defaults to 100 megabytes
60
66
  def index_bloat_bytes
61
- (config["index_bloat_bytes"] || PgHero.config["index_bloat_bytes"] || 100.megabytes).to_i
67
+ (config["index_bloat_bytes"] || PgHero.config["index_bloat_bytes"] || 104857600).to_i
62
68
  end
63
69
 
64
70
  def aws_access_key_id
@@ -73,8 +79,43 @@ module PgHero
73
79
  config["aws_region"] || PgHero.config["aws_region"] || ENV["PGHERO_REGION"] || ENV["AWS_REGION"] || (defined?(Aws) && Aws.config[:region]) || "us-east-1"
74
80
  end
75
81
 
82
+ # environment variable is only used if no config file
76
83
  def aws_db_instance_identifier
77
- @db_instance_identifier ||= config["aws_db_instance_identifier"] || config["db_instance_identifier"]
84
+ @aws_db_instance_identifier ||= config["aws_db_instance_identifier"] || config["db_instance_identifier"]
85
+ end
86
+
87
+ # environment variable is only used if no config file
88
+ def gcp_database_id
89
+ @gcp_database_id ||= config["gcp_database_id"]
90
+ end
91
+
92
+ # environment variable is only used if no config file
93
+ def azure_resource_id
94
+ @azure_resource_id ||= config["azure_resource_id"]
95
+ end
96
+
97
+ # must check keys for booleans
98
+ def filter_data
99
+ unless defined?(@filter_data)
100
+ @filter_data =
101
+ if config.key?("filter_data")
102
+ config["filter_data"]
103
+ elsif PgHero.config.key?("filter_data")
104
+ PgHero.config.key?("filter_data")
105
+ else
106
+ PgHero.filter_data
107
+ end
108
+
109
+ if @filter_data
110
+ begin
111
+ require "pg_query"
112
+ rescue LoadError
113
+ raise Error, "pg_query required for filter_data"
114
+ end
115
+ end
116
+ end
117
+
118
+ @filter_data
78
119
  end
79
120
 
80
121
  # TODO remove in next major version
@@ -85,27 +126,46 @@ module PgHero
85
126
 
86
127
  private
87
128
 
129
+ # check adapter lazily
88
130
  def connection_model
89
- @connection_model ||= begin
90
- url = config["url"]
91
- if !url && config["spec"]
92
- raise Error, "Spec requires Rails 6+" unless PgHero.spec_supported?
93
- resolved = ActiveRecord::Base.configurations.configs_for(env_name: PgHero.env, spec_name: config["spec"], include_replicas: true)
94
- raise Error, "Spec not found: #{config["spec"]}" unless resolved
95
- url = resolved.config
131
+ unless @adapter_checked
132
+ # rough check for Postgres adapter
133
+ # keep this message generic so it's useful
134
+ # when empty url set in Docker image pghero.yml
135
+ unless @connection_model.connection.adapter_name =~ /postg/i
136
+ raise Error, "Invalid connection URL"
96
137
  end
97
- Class.new(PgHero::Connection) do
98
- def self.name
99
- "PgHero::Connection::Database#{object_id}"
100
- end
101
- case url
102
- when String
103
- url = "#{url}#{url.include?("?") ? "&" : "?"}connect_timeout=5" unless url.include?("connect_timeout=")
104
- when Hash
105
- url[:connect_timeout] ||= 5
106
- end
107
- establish_connection url if url
138
+ @adapter_checked = true
139
+ end
140
+
141
+ @connection_model
142
+ end
143
+
144
+ # just return the model
145
+ # do not start a connection
146
+ def build_connection_model
147
+ url = config["url"]
148
+
149
+ # resolve spec
150
+ if !url && config["spec"]
151
+ raise Error, "Spec requires Rails 6+" unless PgHero.spec_supported?
152
+ resolved = ActiveRecord::Base.configurations.configs_for(env_name: PgHero.env, spec_name: config["spec"], include_replicas: true)
153
+ raise Error, "Spec not found: #{config["spec"]}" unless resolved
154
+ url = resolved.config
155
+ end
156
+
157
+ Class.new(PgHero::Connection) do
158
+ def self.name
159
+ "PgHero::Connection::Database#{object_id}"
160
+ end
161
+
162
+ case url
163
+ when String
164
+ url = "#{url}#{url.include?("?") ? "&" : "?"}connect_timeout=5" unless url.include?("connect_timeout=")
165
+ when Hash
166
+ url[:connect_timeout] ||= 5
108
167
  end
168
+ establish_connection url if url
109
169
  end
110
170
  end
111
171
  end