Skip to content

Commit 51d7e17

Browse files
committed
Minimize AWS RDS Proxy connection pinning for PG
1 parent 85734df commit 51d7e17

File tree

6 files changed

+150
-17
lines changed

6 files changed

+150
-17
lines changed

activerecord/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
* Minimize AWS RDS Proxy connection pinning caused by SET for PostgreSQL.
2+
3+
When configuring a PostgreSQL connection, the adapter calls `SET` commands
4+
to configure timezone, encoding, and other parameters. In AWS RDS Proxy,
5+
these `SET` commands cause connection pinning.
6+
7+
This fix skips `SET` commands when the parameter value is already set to the
8+
desired value, minimizing pinning. It's expected that you set these
9+
variables in the RDS Proxy initialization query:
10+
11+
```SQL
12+
SET intervalstyle=iso_8601;SET client_min_messages=warning;
13+
```
14+
15+
*Ivan Yurchanka*
16+
117
* Fix PostgreSQL schema dumping to handle schema-qualified table names in foreign_key references that span different schemas.
218

319
# before
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveRecord
4+
module ConnectionAdapters
5+
module PostgreSQL
6+
# Helper methods for working with configuration parameters.
7+
module ConfigurationParameters
8+
private
9+
def ensure_parameter(name, value)
10+
return if value.nil?
11+
return if parameter_set_to?(name, value)
12+
13+
if block_given?
14+
yield value
15+
else
16+
set_parameter(name, value)
17+
end
18+
end
19+
20+
def parameter_set_to?(name, value)
21+
validate_parameter!(name)
22+
23+
normalized_value = case value
24+
when TrueClass
25+
"on"
26+
when FalseClass
27+
"off"
28+
else
29+
value.to_s
30+
end
31+
32+
current_value = query_value("SHOW #{name}", "SCHEMA")
33+
34+
normalized_value == current_value
35+
end
36+
37+
def set_parameter(name, value)
38+
validate_parameter!(name)
39+
40+
if value == :default
41+
query_command("SET SESSION #{name} TO DEFAULT", "SCHEMA")
42+
else
43+
query_command("SET SESSION #{name} TO #{quote(value)}", "SCHEMA")
44+
end
45+
end
46+
47+
def validate_parameter!(name)
48+
raise ArgumentError, "Parameter name '#{name}' is invalid" unless name.match?(/\A[a-zA-Z0-9_.]+\z/)
49+
end
50+
end
51+
end
52+
end
53+
end

activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ def query_rows(sql, name = "SCHEMA", allow_retry: true, materialize_transactions
1515
intent = internal_build_intent(sql, name, allow_retry:, materialize_transactions:)
1616
intent.execute!
1717
result = intent.raw_result
18-
result.map_types!(@type_map_for_results).values
18+
result.map_types!(@type_map_for_results) if @type_map_for_results
19+
result.values
1920
end
2021

2122
READ_QUERY = ActiveRecord::ConnectionAdapters::AbstractAdapter.build_read_query_regexp(

activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
require "active_record/connection_adapters/abstract_adapter"
88
require "active_record/connection_adapters/statement_pool"
99
require "active_record/connection_adapters/postgresql/column"
10+
require "active_record/connection_adapters/postgresql/configuration_parameters"
1011
require "active_record/connection_adapters/postgresql/database_statements"
1112
require "active_record/connection_adapters/postgresql/explain_pretty_printer"
1213
require "active_record/connection_adapters/postgresql/oid"
@@ -202,6 +203,7 @@ def dbconsole(config, options = {})
202203
include PostgreSQL::ReferentialIntegrity
203204
include PostgreSQL::SchemaStatements
204205
include PostgreSQL::DatabaseStatements
206+
include PostgreSQL::ConfigurationParameters
205207

206208
def supports_bulk_alter?
207209
true
@@ -443,7 +445,7 @@ def self.native_database_types # :nodoc:
443445
end
444446

445447
def set_standard_conforming_strings
446-
query_command("SET standard_conforming_strings = on", "SCHEMA")
448+
ensure_parameter("standard_conforming_strings", "on")
447449
end
448450

449451
def supports_ddl_transactions?
@@ -1029,11 +1031,13 @@ def reconnect
10291031
def configure_connection
10301032
super
10311033

1032-
if @config[:encoding]
1033-
@raw_connection.set_client_encoding(@config[:encoding])
1034+
set_client_encoding
1035+
ensure_parameter("client_min_messages", @config[:min_messages] || "warning") do |value|
1036+
self.client_min_messages = value
1037+
end
1038+
ensure_parameter("search_path", @config[:schema_search_path] || @config[:schema_order]) do |value|
1039+
self.schema_search_path = value
10341040
end
1035-
self.client_min_messages = @config[:min_messages] || "warning"
1036-
self.schema_search_path = @config[:schema_search_path] || @config[:schema_order]
10371041

10381042
unless ActiveRecord.db_warnings_action.nil?
10391043
@raw_connection.set_notice_receiver do |result|
@@ -1047,19 +1051,18 @@ def configure_connection
10471051
# Use standard-conforming strings so we don't have to do the E'...' dance.
10481052
set_standard_conforming_strings
10491053

1050-
variables = @config.fetch(:variables, {}).stringify_keys
1051-
10521054
# Set interval output format to ISO 8601 for ease of parsing by ActiveSupport::Duration.parse
1053-
query_command("SET intervalstyle = iso_8601", "SCHEMA")
1055+
ensure_parameter("intervalstyle", "iso_8601")
10541056

10551057
# SET statements from :variables config hash
10561058
# https://www.postgresql.org/docs/current/static/sql-set.html
1059+
variables = @config.fetch(:variables, {}).stringify_keys
10571060
variables.map do |k, v|
10581061
if v == ":default" || v == :default
10591062
# Sets the value to the global or compile default
1060-
query_command("SET SESSION #{k} TO DEFAULT", "SCHEMA")
1063+
set_parameter(k, :default)
10611064
elsif !v.nil?
1062-
query_command("SET SESSION #{k} TO #{quote(v)}", "SCHEMA")
1065+
ensure_parameter(k, v)
10631066
end
10641067
end
10651068

@@ -1082,13 +1085,29 @@ def reconfigure_connection_timezone
10821085
# If using Active Record's time zone support configure the connection
10831086
# to return TIMESTAMP WITH ZONE types in UTC.
10841087
if default_timezone == :utc
1085-
intent = QueryIntent.new(adapter: self, processed_sql: "SET SESSION timezone TO 'UTC'", name: "SCHEMA")
1086-
intent.execute!
1087-
intent.finish
1088+
# For simplicity, ignore UTC aliases (e.g., UCT, Zulu, GMT, GMT0, ...).
1089+
is_utc = parameter_set_to?("timezone", "UTC") ||
1090+
parameter_set_to?("timezone", "Etc/UTC")
1091+
set_parameter("timezone", "UTC") unless is_utc
10881092
else
1089-
intent = QueryIntent.new(adapter: self, processed_sql: "SET SESSION timezone TO DEFAULT", name: "SCHEMA")
1090-
intent.execute!
1091-
intent.finish
1093+
set_parameter("timezone", :default)
1094+
end
1095+
end
1096+
1097+
def set_client_encoding
1098+
return unless @config[:encoding]
1099+
1100+
normalized_encoding = @config[:encoding].upcase
1101+
1102+
# For simplicity, handle only the common 'unicode' alias.
1103+
# See https://www.postgresql.org/docs/current/multibyte.html#CHARSET-TABLE
1104+
normalized_encoding = "UTF8" if normalized_encoding == "UNICODE"
1105+
1106+
# PostgreSQL accepts loose input forms like 'utf@8'. We assume
1107+
# canonical form for simplicity.
1108+
# See https://github.com/postgres/postgres/blob/master/src/common/encnames.c
1109+
if @raw_connection.get_client_encoding != normalized_encoding
1110+
@raw_connection.set_client_encoding(normalized_encoding)
10921111
end
10931112
end
10941113

activerecord/test/cases/adapters/postgresql/connection_test.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,21 @@ def test_set_session_timezone
200200
end
201201
end
202202

203+
def test_set_invalid_session_variable
204+
run_without_connection do |orig_connection|
205+
assert_raises(ArgumentError) do
206+
ActiveRecord::Base.establish_connection(orig_connection.deep_merge(variables: { 'sql injection': true }))
207+
end
208+
end
209+
end
210+
211+
def test_set_session_encoding
212+
run_without_connection do |orig_connection|
213+
ActiveRecord::Base.establish_connection(orig_connection.deep_merge(encoding: "LATIN1"))
214+
assert_equal "LATIN1", ActiveRecord::Base.lease_connection.select_value("SHOW client_encoding")
215+
end
216+
end
217+
203218
def test_get_and_release_advisory_lock
204219
lock_id = 5295901941911233559
205220
list_advisory_locks = <<~SQL

activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,35 @@ def test_connection_error
2626
assert_kind_of ActiveRecord::ConnectionAdapters::NullPool, error.connection_pool
2727
end
2828

29+
def test_rds_proxy_compatible_connection_avoids_pinning
30+
db_config = ActiveRecord::Base.configurations.configs_for(env_name: "arunit", name: "primary")
31+
configuration = db_config.configuration_hash.merge(
32+
options: [
33+
"-c client_min_messages=warning",
34+
"-c standard_conforming_strings=on",
35+
"-c intervalstyle=iso_8601",
36+
"-c timezone=UTC",
37+
"-c debug_print_plan=on"
38+
].join(" "),
39+
encoding: "unicode",
40+
variables: {
41+
debug_print_plan: true
42+
}
43+
)
44+
connection = ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.new(configuration)
45+
46+
set_commands = []
47+
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
48+
set_commands << payload[:sql] if payload[:sql].match?(/\bSET\b/i)
49+
end
50+
51+
connection.connect!
52+
53+
assert_empty set_commands
54+
ensure
55+
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
56+
end
57+
2958
def test_reconnection_error
3059
fake_connection = Class.new do
3160
def async_exec(*)

0 commit comments

Comments
 (0)