aws_advanced_ruby_driver_wrapper 1.0.0 → 1.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -2
  3. data/README.md +115 -6
  4. data/THIRD-PARTY-LICENSES +70 -11
  5. data/aws_advanced_ruby_driver_wrapper.gemspec +9 -0
  6. data/lib/aws_advanced_ruby_driver_wrapper/active_record/aws_action_cable_postgresql_support.rb +40 -0
  7. data/lib/aws_advanced_ruby_driver_wrapper/active_record/aws_connection_handler.rb +47 -0
  8. data/lib/aws_advanced_ruby_driver_wrapper/active_record/aws_mysql2_adapter.rb +23 -9
  9. data/lib/aws_advanced_ruby_driver_wrapper/active_record/aws_postgresql_adapter.rb +65 -10
  10. data/lib/aws_advanced_ruby_driver_wrapper/active_record/aws_trilogy_adapter.rb +80 -0
  11. data/lib/aws_advanced_ruby_driver_wrapper/active_record/type_adapter_alias.rb +61 -0
  12. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/driver_dialect.rb +48 -0
  13. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/driver_dialect_manager.rb +11 -4
  14. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/mysql2_driver_dialect.rb +129 -0
  15. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/mysql_driver_dialect.rb +9 -81
  16. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/pg_driver_dialect.rb +28 -0
  17. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/trilogy_driver_dialect.rb +118 -0
  18. data/lib/aws_advanced_ruby_driver_wrapper/errors/error_handler.rb +16 -0
  19. data/lib/aws_advanced_ruby_driver_wrapper/errors/mysql_error_handler.rb +10 -5
  20. data/lib/aws_advanced_ruby_driver_wrapper/errors/trilogy_error_handler.rb +127 -0
  21. data/lib/aws_advanced_ruby_driver_wrapper/errors.rb +1 -0
  22. data/lib/aws_advanced_ruby_driver_wrapper/monitoring/cluster_topology_monitor.rb +11 -0
  23. data/lib/aws_advanced_ruby_driver_wrapper/monitoring/monitor.rb +14 -0
  24. data/lib/aws_advanced_ruby_driver_wrapper/monitoring/monitor_connection.rb +6 -0
  25. data/lib/aws_advanced_ruby_driver_wrapper/mysql.rb +27 -9
  26. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/blue_green_plugin.rb +125 -7
  27. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/drop_blue_connections_marker.rb +84 -0
  28. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/host_mapper.rb +3 -1
  29. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/base_routing.rb +7 -0
  30. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/substitute_connect_routing.rb +68 -41
  31. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_connect_routing.rb +4 -2
  32. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_execute_routing.rb +5 -3
  33. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_until_corresponding_host_found_connect_routing.rb +7 -1
  34. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_builder.rb +1 -1
  35. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_monitor.rb +10 -13
  36. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_provider.rb +95 -3
  37. data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/custom_endpoint_monitor.rb +11 -1
  38. data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/custom_endpoint_plugin.rb +4 -1
  39. data/lib/aws_advanced_ruby_driver_wrapper/plugins/default_plugin.rb +14 -3
  40. data/lib/aws_advanced_ruby_driver_wrapper/plugins/failover_plugin.rb +68 -3
  41. data/lib/aws_advanced_ruby_driver_wrapper/plugins/federated_auth/base_saml_auth_plugin.rb +266 -0
  42. data/lib/aws_advanced_ruby_driver_wrapper/plugins/federated_auth/federated_auth_plugin.rb +134 -0
  43. data/lib/aws_advanced_ruby_driver_wrapper/plugins/federated_auth/okta_auth_plugin.rb +113 -0
  44. data/lib/aws_advanced_ruby_driver_wrapper/plugins/gdb/gdb_failover_plugin.rb +30 -9
  45. data/lib/aws_advanced_ruby_driver_wrapper/plugins/iam_auth_plugin.rb +16 -2
  46. data/lib/aws_advanced_ruby_driver_wrapper/plugins/initial_connection_strategy_plugin.rb +4 -1
  47. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/key_management_utility.rb +6 -3
  48. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/key_manager.rb +13 -4
  49. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/kms_encryption_plugin.rb +29 -6
  50. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/sql/trigger_mysql.sql +108 -0
  51. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/sql/trigger_pg.sql +54 -0
  52. data/lib/aws_advanced_ruby_driver_wrapper/plugins/secrets_manager_plugin.rb +23 -1
  53. data/lib/aws_advanced_ruby_driver_wrapper/postgresql.rb +49 -23
  54. data/lib/aws_advanced_ruby_driver_wrapper/property_definition.rb +63 -1
  55. data/lib/aws_advanced_ruby_driver_wrapper/ruby_method.rb +3 -0
  56. data/lib/aws_advanced_ruby_driver_wrapper/services/dialect_service.rb +20 -7
  57. data/lib/aws_advanced_ruby_driver_wrapper/services/host_service.rb +34 -8
  58. data/lib/aws_advanced_ruby_driver_wrapper/services/monitor_service.rb +23 -4
  59. data/lib/aws_advanced_ruby_driver_wrapper/services/plugin_manager.rb +80 -29
  60. data/lib/aws_advanced_ruby_driver_wrapper/services/service_container.rb +10 -2
  61. data/lib/aws_advanced_ruby_driver_wrapper/services/service_utility.rb +4 -1
  62. data/lib/aws_advanced_ruby_driver_wrapper/trilogy.rb +167 -0
  63. data/lib/aws_advanced_ruby_driver_wrapper/utils/connection_config.rb +1 -1
  64. data/lib/aws_advanced_ruby_driver_wrapper/utils/connection_config_parser.rb +25 -15
  65. data/lib/aws_advanced_ruby_driver_wrapper/utils/events/batching_event_publisher.rb +18 -7
  66. data/lib/aws_advanced_ruby_driver_wrapper/utils/global_aurora_topology_utils.rb +2 -2
  67. data/lib/aws_advanced_ruby_driver_wrapper/utils/iam_auth_utils.rb +13 -4
  68. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/encryption_annotation_parser.rb +15 -31
  69. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/mysql_statement_analyzer.rb +55 -76
  70. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/mysql_text.rb +215 -0
  71. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/pg_statement_analyzer.rb +11 -20
  72. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/query_analysis.rb +2 -3
  73. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/sql_parser.rb +21 -14
  74. data/lib/aws_advanced_ruby_driver_wrapper/utils/retry_util.rb +2 -1
  75. data/lib/aws_advanced_ruby_driver_wrapper/utils/saml_utils.rb +133 -0
  76. data/lib/aws_advanced_ruby_driver_wrapper/utils/storage/storage_service.rb +29 -8
  77. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/null_telemetry.rb +89 -0
  78. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/open_telemetry_context.rb +124 -0
  79. data/lib/aws_advanced_ruby_driver_wrapper/utils/{ar_constants.rb → telemetry/open_telemetry_counter.rb} +19 -5
  80. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/open_telemetry_factory.rb +122 -0
  81. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/open_telemetry_gauge.rb +36 -0
  82. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/telemetry_const.rb +50 -0
  83. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/telemetry_factory.rb +143 -0
  84. data/lib/aws_advanced_ruby_driver_wrapper/utils/topology_utils.rb +9 -2
  85. data/lib/aws_advanced_ruby_driver_wrapper/version.rb +1 -1
  86. data/lib/aws_advanced_ruby_driver_wrapper.rb +47 -2
  87. metadata +57 -3
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License").
6
+ # You may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require_relative 'mysql_driver_dialect'
18
+
19
+ module AwsAdvancedRubyDriverWrapper
20
+ module DriverDialects
21
+ # The trilogy driver speaks the same MySQL protocol and grammar as mysql2, so everything that
22
+ # depends only on the server is inherited. What is overridden is how the driver itself is called.
23
+ class TrilogyDriverDialect < MysqlDriverDialect
24
+ # The trilogy calls that talk to the server and that failover should cover. The failover, Blue/Green
25
+ # and Custom Endpoint plugins subscribe to exactly these, and a call that is not listed here and
26
+ # reaches the client's method_missing is handed straight to the driver.
27
+ #
28
+ # Not listed, because trilogy answers them without talking to the server: escape, closed?,
29
+ # more_results_exist?, the counts and ids of the last statement, server_status, in_transaction?,
30
+ # server_version, the query flags and timeouts, and discard!, which only lets go of the socket.
31
+ # A Trilogy::Result is read off the wire in full before it is returned, so reading its rows
32
+ # never talks to the server either.
33
+ #
34
+ # Not listed although it talks to the server: ping, as for mysql2. ActiveRecord pings to check
35
+ # that a connection is still alive and rescues the driver's own errors to answer false and
36
+ # reconnect; failing over inside that check would raise a wrapper error out of it instead.
37
+ NETWORK_BOUND_METHODS = (COMMON_NETWORK_BOUND_METHODS | Set[
38
+ RubyMethod::CONNECTION_QUERY.name,
39
+ RubyMethod::CONNECTION_CHANGE_DB.name,
40
+ RubyMethod::CONNECTION_SELECT_DB.name,
41
+ RubyMethod::CONNECTION_NEXT_RESULT.name,
42
+ RubyMethod::CONNECTION_ABANDON_RESULTS.name,
43
+ RubyMethod::CONNECTION_SET_SERVER_OPTION.name
44
+ ]).freeze
45
+
46
+ # A placeholder, or a quoted string or identifier, matched whole so that a question mark inside
47
+ # one is not taken for a placeholder. Quotes inside are escaped by a backslash or by doubling.
48
+ PLACEHOLDER_OR_QUOTED = /'(?:[^'\\]|\\.|'')*'|"(?:[^"\\]|\\.|"")*"|`(?:[^`]|``)*`|\?/m
49
+
50
+ def name
51
+ 'trilogy'
52
+ end
53
+
54
+ # Trilogy.new stores the options it is given and writes to them, so it is handed a hash of its
55
+ # own, which prepare_connect_config builds afresh on every call.
56
+ def connect(host_info, config)
57
+ ::Trilogy.new(prepare_connect_config(host_info, config))
58
+ end
59
+
60
+ # Trilogy returns rows as arrays. They are keyed by column name here, as mysql2 returns them,
61
+ # because that is the shape the database dialects read their rows in.
62
+ def execute(connection, sql)
63
+ connection.query(sql).each_hash.to_a
64
+ end
65
+
66
+ # Trilogy has no prepared statements, so each parameter is written into the SQL as a literal,
67
+ # quoted and escaped by the connection the way ActiveRecord quotes its own binds for trilogy.
68
+ # Only the types the wrapper binds in its own queries are accepted.
69
+ def execute_with_params(connection, sql, params)
70
+ placeholders = sql.scan(PLACEHOLDER_OR_QUOTED).count('?')
71
+ raise ArgumentError, "SQL has #{placeholders} placeholders for #{params.size} parameters" unless placeholders == params.size
72
+
73
+ literals = params.map { |value| literal(connection, value) }
74
+ bound_sql = sql.gsub(PLACEHOLDER_OR_QUOTED) { |token| token == '?' ? literals.shift : token }
75
+
76
+ execute(connection, bound_sql)
77
+ end
78
+
79
+ # Every reply from the server carries its status flags, and trilogy keeps the last of them, so
80
+ # whether a transaction is open is known without inferring it from the SQL.
81
+ def reported_in_transaction(connection)
82
+ return nil if closed?(connection)
83
+
84
+ connection.in_transaction?
85
+ end
86
+
87
+ # discard! closes the socket without sending COM_QUIT or a TLS close notify, leaving the
88
+ # parent's session open.
89
+ def abandon_connection(connection)
90
+ return if connection.nil? || connection.closed?
91
+
92
+ connection.discard!
93
+ rescue StandardError => e
94
+ logger.debug("Failed to abandon MySQL connection: #{e.message}")
95
+ end
96
+
97
+ def network_bound_methods
98
+ NETWORK_BOUND_METHODS
99
+ end
100
+
101
+ # trilogy names the TLS CA certificate option +ssl_ca+, where mysql2 names it +sslca+.
102
+ def tls_ca_property_key
103
+ :ssl_ca
104
+ end
105
+
106
+ private
107
+
108
+ def literal(connection, value)
109
+ case value
110
+ when nil then 'NULL'
111
+ when Integer then value.to_s
112
+ when String then "'#{connection.escape(value)}'"
113
+ else raise ArgumentError, "Cannot bind a #{value.class} parameter for trilogy"
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -41,6 +41,22 @@ module AwsAdvancedRubyDriverWrapper
41
41
  raise NotImplementedError
42
42
  end
43
43
 
44
+ # Whether the driver reported +error+ as a database error, i.e. it carries a SQLSTATE (or, for
45
+ # trilogy, a MySQL error code). This is not a strict server-vs-client split: mysql2 also reports
46
+ # SQLSTATE HY000 for client-side failures such as a lost connection. Used by the Blue/Green status
47
+ # monitor to tell database errors from other exceptions. A driver whose errors carry no SQLSTATE
48
+ # (trilogy) overrides this to classify by its numeric error code.
49
+ def database_error?(error)
50
+ !@driver_dialect.sql_state(error).nil?
51
+ end
52
+
53
+ # Whether +error+ is a schema/syntax error (SQLSTATE class '42': missing table, unknown column,
54
+ # parse error, missing routine). The Blue/Green status monitor reads this as "the status
55
+ # table/function is not there", i.e. NOT_CREATED. Default is SQLSTATE-based; trilogy overrides.
56
+ def schema_error?(error)
57
+ @driver_dialect.sql_state(error)&.start_with?('42') || false
58
+ end
59
+
44
60
  private
45
61
 
46
62
  def extract_sql_state(error)
@@ -29,7 +29,8 @@ module AwsAdvancedRubyDriverWrapper
29
29
  2006, # CR_SERVER_GONE_ERROR (server has gone away)
30
30
  2013, # CR_SERVER_LOST (lost connection during query)
31
31
  2026, # CR_SSL_CONNECTION_ERROR
32
- 2055 # CR_SERVER_LOST_EXTENDED (lost connection with state info)
32
+ 2055, # CR_SERVER_LOST_EXTENDED (lost connection with state info)
33
+ 4031 # ER_CLIENT_INTERACTION_TIMEOUT (MySQL 8.0.24+ closed a connection that exceeded wait_timeout)
33
34
  ].freeze
34
35
 
35
36
  def initialize(driver_dialect)
@@ -68,12 +69,16 @@ module AwsAdvancedRubyDriverWrapper
68
69
  private
69
70
 
70
71
  def connection_error_network?(error)
71
- return false unless defined?(Mysql2::Error::ConnectionError) && error.is_a?(Mysql2::Error::ConnectionError)
72
+ return false unless defined?(Mysql2::Error) && error.is_a?(Mysql2::Error)
72
73
 
73
- error_number = error.respond_to?(:error_number) ? error.error_number : nil
74
- return true if error_number && NETWORK_ERROR_CODES.include?(error_number)
74
+ NETWORK_ERROR_CODES.include?(error.error_number) || read_timeout?(error)
75
+ end
75
76
 
76
- false
77
+ # mysql2 raises Mysql2::Error::TimeoutError for two unrelated conditions. A client-side read_timeout
78
+ # carries no error number, and mysql2 invalidates the socket before raising it, so the connection is
79
+ # unusable. A lock wait timeout (1205) is a server error on a healthy connection and is excluded.
80
+ def read_timeout?(error)
81
+ error.is_a?(Mysql2::Error::TimeoutError) && error.error_number.nil?
77
82
  end
78
83
  end
79
84
  end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License").
6
+ # You may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require_relative 'error_handler'
18
+
19
+ module AwsAdvancedRubyDriverWrapper
20
+ module Errors
21
+ # Trilogy errors carry no SQLSTATE, so they are classified by class and by the server's error code.
22
+ class TrilogyErrorHandler
23
+ include ErrorHandler
24
+
25
+ LOGIN_ERROR_CODES = Set[
26
+ 1044, # ER_DBACCESS_DENIED_ERROR
27
+ 1045 # ER_ACCESS_DENIED_ERROR
28
+ ].freeze
29
+ READ_ONLY_ERROR_CODES = Set[1290, 1836].freeze
30
+ LOCK_WAIT_TIMEOUT_ERROR_CODE = 1205
31
+
32
+ # MySQL error codes whose SQLSTATE is class '42' (schema/syntax). trilogy carries no SQLSTATE,
33
+ # so the Blue/Green monitor's "is the status table/function gone?" question is answered from
34
+ # the code instead - the same codes libmysql would map to a '42xxx' state for mysql2.
35
+ SCHEMA_ERROR_CODES = Set[
36
+ 1051, # ER_BAD_TABLE_ERROR
37
+ 1054, # ER_BAD_FIELD_ERROR
38
+ 1064, # ER_PARSE_ERROR
39
+ 1109, # ER_UNKNOWN_TABLE
40
+ 1142, # ER_TABLEACCESS_DENIED_ERROR
41
+ 1146, # ER_NO_SUCH_TABLE
42
+ 1305, # ER_SP_DOES_NOT_EXIST
43
+ 1370 # ER_PROCACCESS_DENIED_ERROR
44
+ ].freeze
45
+
46
+ # A protocol violation raised while receiving the authentication response. Unlike a clean
47
+ # ER_ACCESS_DENIED_ERROR (1045), trilogy raises this from its C extension as a codeless
48
+ # Trilogy::QueryError (error_code nil, not a ConnectionError), so it is matched by its message
49
+ # marker rather than by code. The server aborting the auth handshake this way means the
50
+ # credential was not accepted, so for login-retry purposes it is a login error.
51
+ AUTH_PROTOCOL_VIOLATION = 'trilogy_auth_recv'
52
+ PROTOCOL_VIOLATION_MARKER = 'TRILOGY_PROTOCOL_VIOLATION'
53
+
54
+ def initialize(driver_dialect)
55
+ @driver_dialect = driver_dialect
56
+ end
57
+
58
+ # Every Trilogy::ConnectionError except those that do not mean the server is unreachable: rejected
59
+ # credentials, a lock wait timeout, an authentication plugin trilogy refuses (an IAM token without
60
+ # enable_cleartext_plugin) and a connection the application closed. Read and write timeouts count,
61
+ # as they do for mysql2.
62
+ def network_error?(error)
63
+ check_cause_chain(error) { |_, current| network_failure?(current) }
64
+ end
65
+
66
+ def network_error_by_sql_state?(_sql_state)
67
+ false
68
+ end
69
+
70
+ def login_error?(error)
71
+ check_cause_chain(error) do |_, current|
72
+ LOGIN_ERROR_CODES.include?(error_code(current)) || auth_protocol_violation?(current)
73
+ end
74
+ end
75
+
76
+ def login_error_by_sql_state?(_sql_state)
77
+ false
78
+ end
79
+
80
+ def read_only_error?(error)
81
+ check_cause_chain(error) { |_, current| READ_ONLY_ERROR_CODES.include?(error_code(current)) }
82
+ end
83
+
84
+ def read_only_error_by_sql_state?(_sql_state, error_code = nil)
85
+ READ_ONLY_ERROR_CODES.include?(error_code)
86
+ end
87
+
88
+ # Whether the driver reported +error+ as a database error. trilogy carries no SQLSTATE, so this
89
+ # classifies by the presence of the server's numeric error code: a code is present for a server
90
+ # error and absent for a client/transport failure (a Trilogy::BaseConnectionError, a read/write
91
+ # timeout, the codeless auth protocol violation).
92
+ def database_error?(error)
93
+ !error_code(error).nil?
94
+ end
95
+
96
+ def schema_error?(error)
97
+ SCHEMA_ERROR_CODES.include?(error_code(error))
98
+ end
99
+
100
+ private
101
+
102
+ def network_failure?(error)
103
+ return false unless defined?(::Trilogy::ConnectionError) && error.is_a?(::Trilogy::ConnectionError)
104
+ return false if error.is_a?(::Trilogy::ConnectionClosed) || error.is_a?(::Trilogy::AuthPluginError)
105
+
106
+ code = error_code(error)
107
+ !LOGIN_ERROR_CODES.include?(code) && code != LOCK_WAIT_TIMEOUT_ERROR_CODE
108
+ end
109
+
110
+ def error_code(error)
111
+ error.respond_to?(:error_code) ? error.error_code : nil
112
+ end
113
+
114
+ # True for a codeless protocol violation raised during the auth handshake. trilogy surfaces a
115
+ # rejected/aborted credential exchange as a Trilogy::QueryError carrying "TRILOGY_PROTOCOL_VIOLATION
116
+ # (trilogy_auth_recv)" with no MySQL error code, so it is identified by message rather than code.
117
+ # Scoped to the auth phase so a mid-session protocol error is not misread as a login failure.
118
+ def auth_protocol_violation?(error)
119
+ return false unless defined?(::Trilogy::Error) && error.is_a?(::Trilogy::Error)
120
+ return false unless error_code(error).nil?
121
+
122
+ message = error.message.to_s
123
+ message.include?(PROTOCOL_VIOLATION_MARKER) && message.include?(AUTH_PROTOCOL_VIOLATION)
124
+ end
125
+ end
126
+ end
127
+ end
@@ -51,6 +51,7 @@ module AwsAdvancedRubyDriverWrapper
51
51
  end
52
52
 
53
53
  class IamAuthError < AwsError; end
54
+ class SamlAuthError < AwsError; end
54
55
  class SecretsManagerAuthError < AwsError; end
55
56
  class PluginConflictError < AwsError; end
56
57
  class BlueGreenTimeoutError < AwsError; end
@@ -131,6 +131,16 @@ module AwsAdvancedRubyDriverWrapper
131
131
  @instance_monitors_writer_conn.close
132
132
  end
133
133
 
134
+ def abandon_connections
135
+ @monitoring_connection.abandon
136
+ @instance_monitors_writer_conn.abandon
137
+ driver_dialect = @service_container.dialect_service.driver_dialect
138
+ @instance_monitor_connections.each_pair do |host, conn|
139
+ driver_dialect.abandon_connection(conn)
140
+ @instance_monitor_connections.delete(host)
141
+ end
142
+ end
143
+
134
144
  private
135
145
 
136
146
  # Main monitoring loop.
@@ -496,6 +506,7 @@ module AwsAdvancedRubyDriverWrapper
496
506
  end
497
507
 
498
508
  def stored_hosts
509
+ # Explicitly unregistered: a monitor reading its own data must not keep itself alive.
499
510
  storage_service.get(TOPOLOGY_CACHE_NAME, @cluster_id, register_access: false)
500
511
  end
501
512
 
@@ -73,6 +73,20 @@ module AwsAdvancedRubyDriverWrapper
73
73
 
74
74
  def close; end
75
75
 
76
+ # Detaches this monitor in a forked child. Its thread did not survive the fork and its
77
+ # connections still belong to the parent, so the thread reference is dropped and the
78
+ # connections are abandoned rather than closed: closing them would end the parent's sessions.
79
+ def release_after_fork
80
+ @stop_flag.make_true
81
+ @thread = nil
82
+ @state.set(MonitorState::STOPPED)
83
+ abandon_connections
84
+ end
85
+
86
+ # Releases connections inherited from the parent process without closing them on the server.
87
+ # Subclasses that hold connections override this.
88
+ def abandon_connections; end
89
+
76
90
  private
77
91
 
78
92
  def run
@@ -52,6 +52,12 @@ module AwsAdvancedRubyDriverWrapper
52
52
  def close
53
53
  set(nil)
54
54
  end
55
+
56
+ # Releases a connection inherited across a fork without closing it on the server. The reference
57
+ # is dropped too, so a later close or set cannot reach the parent's session.
58
+ def abandon
59
+ @driver_dialect.abandon_connection(@connection.get_and_set(nil))
60
+ end
55
61
  end
56
62
  end
57
63
  end
@@ -20,6 +20,7 @@ require_relative '../aws_advanced_ruby_driver_wrapper'
20
20
  require_relative 'utils/connection_config_parser'
21
21
  require_relative 'utils/sql_encoding'
22
22
  require_relative 'services/service_utility'
23
+ require_relative 'utils/telemetry/telemetry_factory'
23
24
  require_relative 'ruby_method'
24
25
  require_relative 'errors'
25
26
 
@@ -34,13 +35,16 @@ module AwsAdvancedRubyDriverWrapper
34
35
  def initialize(**)
35
36
  ensure_mysql2!
36
37
  config = Utils::ConnectionConfigParser.parse(:mysql2, **)
37
- @service_container = Services::ServiceUtility.create_standard_container(config)
38
- @service_container.host_service.refresh_host_list
39
- @async_conn = nil
40
- @async_sql = nil
41
- @last_sql = nil
42
- conn_service = @service_container.connection_service
43
- @service_container.plugin_manager.connect(conn_service.initial_host_info, conn_service.driver_props, true)
38
+ telemetry_factory = Utils::Telemetry::TelemetryFactory.new(config.wrapper_props)
39
+ telemetry_factory.in_context("#{self.class.name}.new", Utils::Telemetry::TraceLevel::TOP_LEVEL, record_result: true) do
40
+ @service_container = Services::ServiceUtility.create_standard_container(config, telemetry_factory:)
41
+ @service_container.host_service.refresh_host_list
42
+ @async_conn = nil
43
+ @async_sql = nil
44
+ @last_sql = nil
45
+ conn_service = @service_container.connection_service
46
+ @service_container.plugin_manager.connect(conn_service.initial_host_info, conn_service.driver_props, true)
47
+ end
44
48
  end
45
49
 
46
50
  # Explicitly define critical methods (bypass method_missing to avoid method_missing overhead)
@@ -53,6 +57,7 @@ module AwsAdvancedRubyDriverWrapper
53
57
  # What is remembered is the copy of the SQL that plugins inspect, made once here rather than on
54
58
  # every call that is handed it later. The driver is sent the SQL as the application wrote it.
55
59
  def query(sql, options = {})
60
+ ensure_connected!
56
61
  inspected = Utils::SqlEncoding.inspectable(sql)
57
62
  result = pm.execute(RubyMethod::CONNECTION_QUERY, current_conn, ->(*a) { current_conn.query(*a) }, sql, options,
58
63
  sql: inspected)
@@ -76,10 +81,14 @@ module AwsAdvancedRubyDriverWrapper
76
81
  end
77
82
 
78
83
  def ping
84
+ return false if current_conn.nil?
85
+
79
86
  pm.execute(RubyMethod::CONNECTION_PING, current_conn, -> { current_conn.ping })
80
87
  end
81
88
 
82
89
  def close
90
+ return if current_conn.nil?
91
+
83
92
  pm.execute(RubyMethod::CONNECTION_CLOSE, current_conn, -> { current_conn.close })
84
93
  end
85
94
 
@@ -143,8 +152,7 @@ module AwsAdvancedRubyDriverWrapper
143
152
  BOUNDED_TO_ASYNC = Set[:abandon_results!].freeze
144
153
 
145
154
  def method_missing(method_name, *args, **kwargs, &)
146
- conn = current_conn
147
- raise NoMethodError, 'Connection not initialized' if conn.nil?
155
+ conn = ensure_connected!
148
156
  raise NoMethodError, "undefined method `#{method_name}' for #{self.class}" unless conn.respond_to?(method_name)
149
157
 
150
158
  method_key = "connection.#{method_name}"
@@ -197,6 +205,16 @@ module AwsAdvancedRubyDriverWrapper
197
205
  @service_container.connection_service.current_connection
198
206
  end
199
207
 
208
+ # Guards the entry points that reach for the current connection directly (the query path and
209
+ # method_missing). Without a live connection AR expects the driver's own "not connected" error,
210
+ # which it translates to a connection error; a bare NoMethodError on nil would not be recognized.
211
+ def ensure_connected!
212
+ conn = current_conn
213
+ raise Mysql2::Error::ConnectionError, 'MySQL client is not connected' if conn.nil?
214
+
215
+ conn
216
+ end
217
+
200
218
  def pm
201
219
  @service_container.plugin_manager
202
220
  end
@@ -19,6 +19,7 @@ require_relative '../../logging'
19
19
  require_relative '../../ruby_method'
20
20
  require_relative '../../property_definition'
21
21
  require_relative '../../utils/rds_utils'
22
+ require_relative 'drop_blue_connections_marker'
22
23
  require_relative 'phase'
23
24
  require_relative 'role'
24
25
  require_relative 'status'
@@ -32,6 +33,14 @@ module AwsAdvancedRubyDriverWrapper
32
33
 
33
34
  BLUE_GREEN_NAME = :blue_green
34
35
 
36
+ # Storage partition for the drop-blue-connections marker, kept separate from BLUE_GREEN_NAME so
37
+ # it can outlive the status cache's TTL. The marker is stored once per switchover and never
38
+ # re-stamped, so a connection that sits idle longer than the status cache's TTL must still find
39
+ # the marker on its next use. A long TTL is safe: a marker only matches connections opened before
40
+ # its own switchover, and a later switchover overwrites it.
41
+ DROP_MARKER_NAME = :blue_green_drop_marker
42
+ DROP_MARKER_TTL_SEC = 86_400
43
+
35
44
  # When present in wrapper_props, internal_connect calls bypass BG routing.
36
45
  # Used by BG monitoring connections to avoid being intercepted by their own plugin.
37
46
  BG_SKIP_ROUTING_KEY = :'bg.skip_routing'
@@ -43,6 +52,13 @@ module AwsAdvancedRubyDriverWrapper
43
52
 
44
53
  PROVIDERS = Concurrent::Map.new
45
54
 
55
+ # Storage key, within the DROP_MARKER_NAME cache, under which the drop-blue-connections marker
56
+ # for a deployment is kept. DROP_MARKER_NAME is a separate partition from the status cache, so
57
+ # the marker survives both the status cache's TTL and the provider's reset_context_when_completed.
58
+ def self.drop_marker_key(bgd_id)
59
+ "#{bgd_id}::drop_blue_connections_marker"
60
+ end
61
+
46
62
  def initialize(service_container, props = ::Concurrent::Map.new)
47
63
  @service_container = service_container
48
64
  @wrapper_props = props
@@ -52,7 +68,17 @@ module AwsAdvancedRubyDriverWrapper
52
68
  @start_time_ns = Concurrent::AtomicReference.new(0)
53
69
  @end_time_ns = Concurrent::AtomicReference.new(0)
54
70
 
71
+ # Per-connection drop-blue-connections state. Each wrapper client has its own plugin instance,
72
+ # so these describe this connection: when it was opened, the host it was opened to, and the
73
+ # identity of the last marker it was checked against.
74
+ @drop_blue_connections = PropertyDefinition::BG_DROP_BLUE_CONNECTIONS.get_bool(props)
75
+ @connection_open_time_ns = nil
76
+ @connect_time_host = nil
77
+ @checked_drop_marker_id = nil
78
+ @pending_stale_marker_id = nil
79
+
55
80
  service_container.storage_service.register(BLUE_GREEN_NAME, ttl: 3600)
81
+ service_container.storage_service.register(DROP_MARKER_NAME, ttl: DROP_MARKER_TTL_SEC)
56
82
 
57
83
  network_methods = service_container.dialect_service.driver_dialect.network_bound_methods
58
84
  @subscribed_methods = (Set['connect', 'internal_connect'] | network_methods).freeze
@@ -61,7 +87,12 @@ module AwsAdvancedRubyDriverWrapper
61
87
  attr_reader :subscribed_methods
62
88
 
63
89
  def connect(host_info, driver_props, is_initial_connection, pipeline_callable)
64
- route_connect(host_info, driver_props, is_initial_connection, pipeline_callable, is_internal: false)
90
+ conn = route_connect(host_info, driver_props, is_initial_connection, pipeline_callable, is_internal: false)
91
+ # Only this client's own connect describes its connection. Internal connects (topology
92
+ # monitoring, KMS metadata reads) run through this same plugin instance but open separate
93
+ # connections, so they must not overwrite this connection's open time, host, or marker latches.
94
+ record_connection_open(host_info)
95
+ conn
65
96
  end
66
97
 
67
98
  def internal_connect(host_info, driver_props, wrapper_props, is_initial_connection, pipeline_callable)
@@ -79,7 +110,21 @@ module AwsAdvancedRubyDriverWrapper
79
110
 
80
111
  return pipeline_callable.call if CLOSING_METHODS.include?(method_name)
81
112
 
113
+ # Read the status before the stale check. The provider stores the active drop marker before
114
+ # it writes the POST/COMPLETED status, so reading status first closes the race where a stale
115
+ # check sees a still-pending marker and the COMPLETED early return below then skips re-checking
116
+ # the now-active marker: if the switchover has completed, the check below sees the active marker.
82
117
  @bg_status = storage_service.get(BLUE_GREEN_NAME, @bgd_id)
118
+
119
+ # Invalidate a connection opened to the old blue environment before a confirmed switchover,
120
+ # so the failover plugin reconnects through BG routing instead of serving a stale read. This
121
+ # runs before the NOT_CREATED/COMPLETED early return below, because after the switchover the
122
+ # status is COMPLETED (then NOT_CREATED) and would otherwise skip the check entirely.
123
+ if stale_blue_connection?
124
+ invalidate_stale_blue_connection
125
+ return pipeline_callable.call
126
+ end
127
+
83
128
  if @bg_status.nil? || @bg_status.current_phase == Phase::NOT_CREATED || @bg_status.current_phase == Phase::COMPLETED
84
129
  return pipeline_callable.call
85
130
  end
@@ -95,20 +140,24 @@ module AwsAdvancedRubyDriverWrapper
95
140
  result = nil
96
141
 
97
142
  while routing && result.nil?
98
- result = routing.apply(method_name, @wrapper_props, storage_service)
143
+ result = routing.apply(method_name, @wrapper_props, storage_service, telemetry_factory: @service_container.telemetry_factory)
99
144
 
100
145
  next unless result.nil?
101
146
 
102
147
  @bg_status = storage_service.get(BLUE_GREEN_NAME, @bgd_id)
103
- if @bg_status.nil?
104
- @end_time_ns.set(nano_time)
105
- return pipeline_callable.call
106
- end
148
+ break if @bg_status.nil?
149
+
107
150
  routing = @bg_status.execute_routing.find { |r| r.match?(current_host, host_role) }
108
151
  end
109
152
 
110
153
  @end_time_ns.set(nano_time)
111
- result.nil? ? pipeline_callable.call : result
154
+ return result unless result.nil?
155
+
156
+ # The call may have been held through the switchover (e.g. suspended during IN_PROGRESS and
157
+ # released at POST, which has no execute routing). Re-check whether this connection is now a
158
+ # stale blue connection before running it, so a held call is not run against retired blue.
159
+ invalidate_stale_blue_connection if stale_blue_connection?
160
+ pipeline_callable.call
112
161
  ensure
113
162
  @end_time_ns.set(nano_time) if @start_time_ns.get.positive? && @end_time_ns.get.zero?
114
163
  end
@@ -130,6 +179,16 @@ module AwsAdvancedRubyDriverWrapper
130
179
  PROVIDERS.each_key { |k| PROVIDERS.delete(k)&.stop }
131
180
  end
132
181
 
182
+ # Forgets the providers inherited by a forked child so the child starts its own on first use.
183
+ # A provider that fails to release is still forgotten, and the rest are released.
184
+ def self.release_providers_after_fork
185
+ PROVIDERS.each_key do |key|
186
+ PROVIDERS.delete(key)&.release_after_fork
187
+ rescue StandardError => e
188
+ AwsAdvancedRubyDriverWrapper.logger.warn("Failed to release Blue/Green status provider #{key} after fork: #{e.message}")
189
+ end
190
+ end
191
+
133
192
  private
134
193
 
135
194
  def route_connect(host_info, driver_props, is_initial_connection, pipeline_callable, is_internal: false)
@@ -192,6 +251,65 @@ module AwsAdvancedRubyDriverWrapper
192
251
  end
193
252
  end
194
253
 
254
+ # Remembers when this connection was opened and the host it was opened to, so a later switchover
255
+ # can tell whether it is a blue connection that predates the switchover.
256
+ def record_connection_open(host_info)
257
+ return unless @drop_blue_connections
258
+
259
+ @connection_open_time_ns = nano_time
260
+ @connect_time_host = host_info&.host&.downcase
261
+ # A (re)connect produces a fresh connection (e.g. after failover): reset the per-connection
262
+ # marker latches so this new incarnation re-evaluates the current marker from a clean slate
263
+ # instead of inheriting the retired connection's checked/pending state.
264
+ @checked_drop_marker_id = nil
265
+ @pending_stale_marker_id = nil
266
+ end
267
+
268
+ # Whether this connection should be invalidated because it was opened to the old blue environment
269
+ # before a now-confirmed switchover. The marker is read on every call so a later switchover's
270
+ # marker is noticed. A marker that does not apply to this connection is latched immediately and
271
+ # never re-examined; a marker that does apply is only latched once #invalidate_stale_blue_connection
272
+ # confirms the close, so a transient close failure leaves the connection to be invalidated again on
273
+ # its next use instead of silently serving stale reads for the rest of its life.
274
+ def stale_blue_connection?
275
+ return false unless @drop_blue_connections
276
+
277
+ marker = storage_service.get(DROP_MARKER_NAME, self.class.drop_marker_key(@bgd_id), register_access: false)
278
+ return false unless marker.is_a?(DropBlueConnectionsMarker) && marker.active?
279
+ return false if marker.id == @checked_drop_marker_id
280
+
281
+ if marker.stale?(@connect_time_host, @connection_open_time_ns)
282
+ @pending_stale_marker_id = marker.id
283
+ true
284
+ else
285
+ # This connection does not predate the switchover and never will against this marker (markers
286
+ # are immutable), so latch it now to skip re-reading the marker on every future call.
287
+ @checked_drop_marker_id = marker.id
288
+ false
289
+ end
290
+ end
291
+
292
+ # Closes the connection directly through the driver dialect rather than the pipeline's close
293
+ # method. Going through the pipeline would set the failover plugin's @closed_explicitly, which
294
+ # would stop it from reconnecting. Closing it out from under the pipeline instead lets the
295
+ # failover plugin (if enabled) see a connection closed that it did not close, fail over, and
296
+ # raise FailoverSuccessError; without failover the driver raises its closed-connection error.
297
+ def invalidate_stale_blue_connection
298
+ logger.info do
299
+ "[bgd_id: '#{@bgd_id}'] Invalidating a connection to #{@connect_time_host} from the old blue environment " \
300
+ 'retired by a Blue/Green switchover. It will fail over (or raise a closed-connection error) on this use.'
301
+ end
302
+
303
+ conn = @service_container.connection_service.current_connection
304
+ @service_container.dialect_service.driver_dialect.close_connection(conn) unless conn.nil?
305
+ # Only now that the close is confirmed do we latch the marker as handled. If the close raised
306
+ # and was swallowed below, the marker stays unlatched so the next use retries the invalidation
307
+ # rather than serving a stale read from retired blue for the rest of the connection's life.
308
+ @checked_drop_marker_id = @pending_stale_marker_id
309
+ rescue StandardError => e
310
+ logger.debug { "[bgd_id: '#{@bgd_id}'] Failed to invalidate stale blue connection: #{e.class}: #{e.message}" }
311
+ end
312
+
195
313
  def storage_service
196
314
  @service_container.storage_service
197
315
  end