diff --git a/README.md b/README.md index 2cc0863..0989d55 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,11 @@ end * PostgreSQL: if a statement fails and the block rescues the error, PostgreSQL still rolls the whole transaction back, so `transaction` raises `PG::InFailedSqlTransaction` instead of pretending it committed. In a nested `transaction` only the savepoint is rolled back: the outer block can rescue that error and go on. +* MySQL: a deadlock (or a lock wait timeout with `innodb_rollback_on_timeout`) rolls the whole transaction back on + the server, its savepoints too. If the block rescues that error, every later query in the transaction and + `transaction` itself raise it again (a `Mysql2::Error` with the same `error_number`, its message prefixed with + `transaction rolled back:`) instead of running in autocommit mode and reporting a commit; retry the whole + `transaction`. A lock wait timeout without that setting fails only its statement: the block can rescue it and go on. * A connection the server closed (restart, failover, killed backend) is reconnected (`pg`) or replaced in the pool (`mysql2`) the next time it is checked out; the call that hit the dead connection still raises. Inside `transaction` a lost `pg` connection is not reconnected: every later call in the block and `transaction` itself raise diff --git a/docs/Coverage.md b/docs/Coverage.md index 6e2e4e7..bee7349 100644 --- a/docs/Coverage.md +++ b/docs/Coverage.md @@ -23,7 +23,7 @@ only when the behavior is rejected or decided during generation and generated co | SQLite `busy_handler_timeout=` waits for another connection without blocking other threads | not SQLite | not SQLite | [`test_busy_handler_timeout_waits_for_another_connection_without_blocking_threads`](../tests/end2end_sqlite3.rb#test_busy_handler_timeout_waits_for_another_connection_without_blocking_threads) | | | Names: keywords, method collisions (also private row hooks such as `initialize`), acronyms, quoted mixed case, repeated `$n`, plural models | [`test_keyword_and_method_names_keep_distinct_row_and_argument_names`](../tests/end2end_pg.rb#test_keyword_and_method_names_keep_distinct_row_and_argument_names), [`test_repeated_column_parameters_get_distinct_names`](../tests/end2end_pg.rb#test_repeated_column_parameters_get_distinct_names) | [`test_keyword_and_method_names_keep_distinct_row_and_argument_names`](../tests/end2end_mysql2.rb#test_keyword_and_method_names_keep_distinct_row_and_argument_names), [`test_repeated_positional_parameters_get_distinct_names`](../tests/end2end_mysql2.rb#test_repeated_positional_parameters_get_distinct_names) | [`test_keyword_and_method_names_keep_distinct_row_and_argument_names`](../tests/end2end_sqlite3.rb#test_keyword_and_method_names_keep_distinct_row_and_argument_names) | [`test_snake_case_keeps_acronyms_together`](../test/features_test.rb#test_snake_case_keeps_acronyms_together), [`test_query_names_cannot_replace_generated_methods`](../test/features_test.rb#test_query_names_cannot_replace_generated_methods), [`test_tables_with_the_same_singular_get_distinct_models`](../test/features_test.rb#test_tables_with_the_same_singular_get_distinct_models), [`test_columns_named_like_row_methods_still_build_and_read_rows`](../test/features_test.rb#test_columns_named_like_row_methods_still_build_and_read_rows) | | Connections, injected pools, reconnect behavior and SQLite serialization | [`test_queries_can_share_an_injected_pool`](../tests/end2end_pg.rb#test_queries_can_share_an_injected_pool), [`test_a_connection_closed_by_the_server_is_reconnected`](../tests/end2end_pg.rb#test_a_connection_closed_by_the_server_is_reconnected), [`test_connection_lost_inside_transaction_is_not_reconnected_until_the_transaction_ends`](../tests/end2end_pg.rb#test_connection_lost_inside_transaction_is_not_reconnected_until_the_transaction_ends) | [`test_queries_can_share_an_injected_pool`](../tests/end2end_mysql2.rb#test_queries_can_share_an_injected_pool), [`test_a_connection_closed_by_the_server_is_replaced`](../tests/end2end_mysql2.rb#test_a_connection_closed_by_the_server_is_replaced), [`test_a_stopped_async_task_does_not_leave_a_closed_connection_in_the_pool`](../tests/end2end_mysql2.rb#test_a_stopped_async_task_does_not_leave_a_closed_connection_in_the_pool) | [`test_fibers_sharing_a_database_take_turns`](../tests/end2end_sqlite3.rb#test_fibers_sharing_a_database_take_turns), [`test_threads_sharing_a_database_take_turns_in_transactions`](../tests/end2end_sqlite3.rb#test_threads_sharing_a_database_take_turns_in_transactions), [`test_a_database_returning_hash_rows_still_builds_rows`](../tests/end2end_sqlite3.rb#test_a_database_returning_hash_rows_still_builds_rows) | [`test_engine_must_match_the_driver`](../test/features_test.rb#test_engine_must_match_the_driver) | -| Transaction result, commit/rollback, nested savepoint, exit semantics and rescued PostgreSQL error | [`test_nested_savepoint_rolls_back_while_outer_transaction_commits`](../tests/end2end_pg.rb#test_nested_savepoint_rolls_back_while_outer_transaction_commits), [`test_transaction_nonlocal_exit_semantics_follow_timeout_version`](../tests/end2end_pg.rb#test_transaction_nonlocal_exit_semantics_follow_timeout_version), [`test_transaction_whose_failed_statement_was_rescued_raises_instead_of_committing`](../tests/end2end_pg.rb#test_transaction_whose_failed_statement_was_rescued_raises_instead_of_committing) | [`test_nested_savepoint_rolls_back_while_outer_transaction_commits`](../tests/end2end_mysql2.rb#test_nested_savepoint_rolls_back_while_outer_transaction_commits), [`test_transaction_nonlocal_exit_semantics_follow_timeout_version`](../tests/end2end_mysql2.rb#test_transaction_nonlocal_exit_semantics_follow_timeout_version) | [`test_nested_transaction_rolls_back_to_a_savepoint`](../tests/end2end_sqlite3.rb#test_nested_transaction_rolls_back_to_a_savepoint), [`test_transaction_nonlocal_exit_semantics_follow_timeout_version`](../tests/end2end_sqlite3.rb#test_transaction_nonlocal_exit_semantics_follow_timeout_version) | [`test_nested_transactions_reject_isolation_and_mode`](../test/features_test.rb#test_nested_transactions_reject_isolation_and_mode) | +| Transaction result, commit/rollback, nested savepoint, exit semantics, rescued PostgreSQL error and rescued MySQL deadlock / lock wait timeout | [`test_nested_savepoint_rolls_back_while_outer_transaction_commits`](../tests/end2end_pg.rb#test_nested_savepoint_rolls_back_while_outer_transaction_commits), [`test_transaction_nonlocal_exit_semantics_follow_timeout_version`](../tests/end2end_pg.rb#test_transaction_nonlocal_exit_semantics_follow_timeout_version), [`test_transaction_whose_failed_statement_was_rescued_raises_instead_of_committing`](../tests/end2end_pg.rb#test_transaction_whose_failed_statement_was_rescued_raises_instead_of_committing) | [`test_nested_savepoint_rolls_back_while_outer_transaction_commits`](../tests/end2end_mysql2.rb#test_nested_savepoint_rolls_back_while_outer_transaction_commits), [`test_transaction_nonlocal_exit_semantics_follow_timeout_version`](../tests/end2end_mysql2.rb#test_transaction_nonlocal_exit_semantics_follow_timeout_version), [`test_rescued_deadlock_in_a_transaction_raises_instead_of_committing`](../tests/end2end_mysql2.rb#test_rescued_deadlock_in_a_transaction_raises_instead_of_committing), [`test_rescued_deadlock_in_a_nested_transaction_raises_instead_of_committing`](../tests/end2end_mysql2.rb#test_rescued_deadlock_in_a_nested_transaction_raises_instead_of_committing), [`test_rescued_lock_wait_timeout_rolls_back_only_its_statement`](../tests/end2end_mysql2.rb#test_rescued_lock_wait_timeout_rolls_back_only_its_statement) | [`test_nested_transaction_rolls_back_to_a_savepoint`](../tests/end2end_sqlite3.rb#test_nested_transaction_rolls_back_to_a_savepoint), [`test_transaction_nonlocal_exit_semantics_follow_timeout_version`](../tests/end2end_sqlite3.rb#test_transaction_nonlocal_exit_semantics_follow_timeout_version) | [`test_nested_transactions_reject_isolation_and_mode`](../test/features_test.rb#test_nested_transactions_reject_isolation_and_mode) | | Isolation applied; invalid isolation/mode rejected with `ArgumentError` before begin; SQLite locks | [`test_transaction_isolation_level`](../tests/end2end_pg.rb#test_transaction_isolation_level) | [`test_transaction_isolation_level_is_applied`](../tests/end2end_mysql2.rb#test_transaction_isolation_level_is_applied) | [`test_transaction_mode`](../tests/end2end_sqlite3.rb#test_transaction_mode), [`test_immediate_and_exclusive_transactions_block_a_second_connection`](../tests/end2end_sqlite3.rb#test_immediate_and_exclusive_transactions_block_a_second_connection), [`test_unknown_transaction_mode_is_rejected_before_beginning`](../tests/end2end_sqlite3.rb#test_unknown_transaction_mode_is_rejected_before_beginning) | [`test_nested_transactions_reject_isolation_and_mode`](../test/features_test.rb#test_nested_transactions_reject_isolation_and_mode) | | Logger debug format, transaction logs, secret-free parameters | [`test_logger_receives_query_names_and_durations`](../tests/end2end_pg.rb#test_logger_receives_query_names_and_durations) | [`test_logger_receives_query_names_and_durations`](../tests/end2end_mysql2.rb#test_logger_receives_query_names_and_durations) | [`test_logger_receives_query_names_and_durations`](../tests/end2end_sqlite3.rb#test_logger_receives_query_names_and_durations) | [`test_logger_support`](../test/features_test.rb#test_logger_support) | | Fiber scheduler concurrency / MySQL no prepared-statement leaks | [`test_queries_run_concurrently_under_the_fiber_scheduler`](../tests/end2end_pg.rb#test_queries_run_concurrently_under_the_fiber_scheduler) | [`test_generated_queries_overlap_under_sync_including_transactions`](../tests/end2end_mysql2.rb#test_generated_queries_overlap_under_sync_including_transactions), [`test_generated_queries_do_not_create_server_side_prepared_statements`](../tests/end2end_mysql2.rb#test_generated_queries_do_not_create_server_side_prepared_statements) | [`test_fibers_sharing_a_database_take_turns`](../tests/end2end_sqlite3.rb#test_fibers_sharing_a_database_take_turns) | | diff --git a/examples/mysql2/connection.rb b/examples/mysql2/connection.rb index e4192c7..4d4b1ec 100644 --- a/examples/mysql2/connection.rb +++ b/examples/mysql2/connection.rb @@ -6,19 +6,49 @@ module Mysql2Codegen # State kept on every Mysql2::Client the first time it is used. module ConnectionState attr_accessor :in_transaction + # The error with which the server rolled back the whole transaction (a deadlock, or a lock wait timeout with + # innodb_rollback_on_timeout): the transaction's later queries raise instead of running in autocommit mode. + attr_accessor :rolled_back_by # Stays true when a Thread#kill interrupts the query (it skips rescue): see #with_client. def query(...) + raise rolled_back_error if in_transaction && rolled_back_by + @query_running = true result = super @query_running = false result + rescue ::Mysql2::Error => e + @query_running = false + self.rolled_back_by ||= e if in_transaction && ends_transaction?(e) + raise rescue ::Exception # rubocop:disable Lint/RescueException @query_running = false raise end def query_running? = @query_running == true + + def rolled_back_error + error = rolled_back_by + error.class.new("transaction rolled back: #{error.message}", nil, error.error_number, error.sql_state) + end + + # ER_LOCK_DEADLOCK always rolls back the whole transaction, ER_LOCK_WAIT_TIMEOUT only with + # innodb_rollback_on_timeout (otherwise just the statement). + def ends_transaction?(error) + return true if error.error_number == 1213 + return false unless error.error_number == 1205 + + if @rollback_on_timeout.nil? + @rollback_on_timeout = begin + query('SELECT @@innodb_rollback_on_timeout', as: :array).first.first == 1 + rescue ::Mysql2::Error + false + end + end + @rollback_on_timeout + end end # Raised when the query object yielded by #transaction is used after the transaction ended. diff --git a/examples/mysql2/connection.rbs b/examples/mysql2/connection.rbs index a9bdc58..e720c06 100644 --- a/examples/mysql2/connection.rbs +++ b/examples/mysql2/connection.rbs @@ -2,8 +2,11 @@ module Mysql2Codegen module ConnectionState attr_accessor in_transaction: bool + attr_accessor rolled_back_by: untyped def query: (*untyped) -> untyped def query_running?: () -> bool + def rolled_back_error: () -> untyped + def ends_transaction?: (untyped error) -> bool end class TransactionEnded < ::StandardError diff --git a/examples/mysql2/query_sql.rb b/examples/mysql2/query_sql.rb index eeda748..efc52c4 100644 --- a/examples/mysql2/query_sql.rb +++ b/examples/mysql2/query_sql.rb @@ -572,7 +572,7 @@ def transaction(isolation: nil) failed = true bound&.close begin - client.query(rollback) if started || outermost + client.query(rollback) if (started || outermost) && !client.rolled_back_by rescue ::Mysql2::Error # a deadlock / lock wait timeout already rolled back the transaction: its savepoints are gone; # an interrupted query closed the connection: the server rolls back @@ -587,6 +587,10 @@ def transaction(isolation: nil) # Thread#kill interrupted a query: the client still waits for its result and cannot send # ROLLBACK. Closing it makes the server roll back; #with_client discards it. client.close + elsif client.rolled_back_by + # the server already rolled the whole transaction back (its savepoints too): COMMIT would only + # end the statements run since, so raise instead of reporting a commit + raise client.rolled_back_error if ::Thread.current.status != 'aborting' else commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" begin @@ -604,7 +608,10 @@ def transaction(isolation: nil) end end ensure - client.in_transaction = false if outermost + if outermost + client.in_transaction = false + client.rolled_back_by = nil + end end end end @@ -1573,7 +1580,7 @@ def load_data(client, sql, strict: true) ensure if outermost client.query(done ? 'COMMIT' : 'ROLLBACK') - else + elsif !client.rolled_back_by # a deadlock rolled the whole transaction back, its savepoint too client.query(done ? 'RELEASE SAVEPOINT sqlc_copy' : 'ROLLBACK TO SAVEPOINT sqlc_copy') end end diff --git a/lib/sqlc_gen_ruby/drivers/mysql2.rb b/lib/sqlc_gen_ruby/drivers/mysql2.rb index 99471c5..a1c9c24 100644 --- a/lib/sqlc_gen_ruby/drivers/mysql2.rb +++ b/lib/sqlc_gen_ruby/drivers/mysql2.rb @@ -11,7 +11,9 @@ def name = 'Mysql2' def module_members [ [CONNECTION_STATE, "module ConnectionState\n attr_accessor in_transaction: bool\n" \ - " def query: (*untyped) -> untyped\n def query_running?: () -> bool\nend"], + " attr_accessor rolled_back_by: untyped\n" \ + " def query: (*untyped) -> untyped\n def query_running?: () -> bool\n" \ + " def rolled_back_error: () -> untyped\n def ends_transaction?: (untyped error) -> bool\nend"], [TRANSACTION_CONNECTION, TRANSACTION_CONNECTION_RBS], [ISOLATION_LEVELS, ISOLATION_RBS] ] @@ -136,19 +138,49 @@ def helper_methods(queries) # State kept on every Mysql2::Client the first time it is used. module ConnectionState attr_accessor :in_transaction + # The error with which the server rolled back the whole transaction (a deadlock, or a lock wait timeout with + # innodb_rollback_on_timeout): the transaction's later queries raise instead of running in autocommit mode. + attr_accessor :rolled_back_by # Stays true when a Thread#kill interrupts the query (it skips rescue): see #with_client. def query(...) + raise rolled_back_error if in_transaction && rolled_back_by + @query_running = true result = super @query_running = false result + rescue ::Mysql2::Error => e + @query_running = false + self.rolled_back_by ||= e if in_transaction && ends_transaction?(e) + raise rescue ::Exception # rubocop:disable Lint/RescueException @query_running = false raise end def query_running? = @query_running == true + + def rolled_back_error + error = rolled_back_by + error.class.new("transaction rolled back: #{error.message}", nil, error.error_number, error.sql_state) + end + + # ER_LOCK_DEADLOCK always rolls back the whole transaction, ER_LOCK_WAIT_TIMEOUT only with + # innodb_rollback_on_timeout (otherwise just the statement). + def ends_transaction?(error) + return true if error.error_number == 1213 + return false unless error.error_number == 1205 + + if @rollback_on_timeout.nil? + @rollback_on_timeout = begin + query('SELECT @@innodb_rollback_on_timeout', as: :array).first.first == 1 + rescue ::Mysql2::Error + false + end + end + @rollback_on_timeout + end end RUBY @@ -206,7 +238,7 @@ def transaction(isolation: nil) failed = true bound&.close begin - client.query(rollback) if started || outermost + client.query(rollback) if (started || outermost) && !client.rolled_back_by rescue ::Mysql2::Error # a deadlock / lock wait timeout already rolled back the transaction: its savepoints are gone; # an interrupted query closed the connection: the server rolls back @@ -221,6 +253,10 @@ def transaction(isolation: nil) # Thread#kill interrupted a query: the client still waits for its result and cannot send # ROLLBACK. Closing it makes the server roll back; #with_client discards it. client.close + elsif client.rolled_back_by + # the server already rolled the whole transaction back (its savepoints too): COMMIT would only + # end the statements run since, so raise instead of reporting a commit + raise client.rolled_back_error if ::Thread.current.status != 'aborting' else commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" begin @@ -238,7 +274,10 @@ def transaction(isolation: nil) end end ensure - client.in_transaction = false if outermost + if outermost + client.in_transaction = false + client.rolled_back_by = nil + end end end end @@ -295,7 +334,7 @@ def load_data(client, sql, strict: true) ensure if outermost client.query(done ? 'COMMIT' : 'ROLLBACK') - else + elsif !client.rolled_back_by # a deadlock rolled the whole transaction back, its savepoint too client.query(done ? 'RELEASE SAVEPOINT sqlc_copy' : 'ROLLBACK TO SAVEPOINT sqlc_copy') end end diff --git a/steep_expectations.yml b/steep_expectations.yml index 523bfd3..34a96c8 100644 --- a/steep_expectations.yml +++ b/steep_expectations.yml @@ -1,4 +1,26 @@ --- +- file: examples/mysql2/connection.rb + diagnostics: + - range: + start: + line: 21 + character: 13 + end: + line: 21 + character: 19 + severity: WARNING + message: 'Cannot find the declaration of constant: `Mysql2`' + code: Ruby::UnknownConstant + - range: + start: + line: 46 + character: 17 + end: + line: 46 + character: 23 + severity: WARNING + message: 'Cannot find the declaration of constant: `Mysql2`' + code: Ruby::UnknownConstant - file: examples/mysql2/query_sql.rb diagnostics: - range: @@ -36,7 +58,7 @@ line: 556 character: 54 end: - line: 610 + line: 617 character: 11 severity: WARNING message: The method cannot be called with a block @@ -73,20 +95,20 @@ code: Ruby::UnknownConstant - range: start: - line: 599 + line: 603 character: 29 end: - line: 599 + line: 603 character: 35 severity: WARNING message: 'Cannot find the declaration of constant: `Mysql2`' code: Ruby::UnknownConstant - range: start: - line: 1516 + line: 1523 character: 20 end: - line: 1516 + line: 1523 character: 38 severity: ERROR message: |- @@ -99,20 +121,20 @@ code: Ruby::ArgumentTypeMismatch - range: start: - line: 1517 + line: 1524 character: 78 end: - line: 1517 + line: 1524 character: 84 severity: ERROR message: Type `(::String | nil)` does not have method `match?` code: Ruby::NoMethod - range: start: - line: 1519 + line: 1526 character: 20 end: - line: 1519 + line: 1526 character: 39 severity: ERROR message: |- @@ -125,10 +147,10 @@ code: Ruby::ArgumentTypeMismatch - range: start: - line: 1523 + line: 1530 character: 20 end: - line: 1523 + line: 1530 character: 39 severity: ERROR message: |- @@ -141,10 +163,10 @@ code: Ruby::ArgumentTypeMismatch - range: start: - line: 1528 + line: 1535 character: 20 end: - line: 1528 + line: 1535 character: 39 severity: ERROR message: |- @@ -157,10 +179,10 @@ code: Ruby::ArgumentTypeMismatch - range: start: - line: 1537 + line: 1544 character: 20 end: - line: 1537 + line: 1544 character: 24 severity: ERROR message: |- @@ -173,10 +195,10 @@ code: Ruby::ArgumentTypeMismatch - range: start: - line: 1570 + line: 1577 character: 18 end: - line: 1570 + line: 1577 character: 24 severity: WARNING message: 'Cannot find the declaration of constant: `Mysql2`' diff --git a/test/golden/mysql2/default/connection.rb b/test/golden/mysql2/default/connection.rb index e4192c7..4d4b1ec 100644 --- a/test/golden/mysql2/default/connection.rb +++ b/test/golden/mysql2/default/connection.rb @@ -6,19 +6,49 @@ module Mysql2Codegen # State kept on every Mysql2::Client the first time it is used. module ConnectionState attr_accessor :in_transaction + # The error with which the server rolled back the whole transaction (a deadlock, or a lock wait timeout with + # innodb_rollback_on_timeout): the transaction's later queries raise instead of running in autocommit mode. + attr_accessor :rolled_back_by # Stays true when a Thread#kill interrupts the query (it skips rescue): see #with_client. def query(...) + raise rolled_back_error if in_transaction && rolled_back_by + @query_running = true result = super @query_running = false result + rescue ::Mysql2::Error => e + @query_running = false + self.rolled_back_by ||= e if in_transaction && ends_transaction?(e) + raise rescue ::Exception # rubocop:disable Lint/RescueException @query_running = false raise end def query_running? = @query_running == true + + def rolled_back_error + error = rolled_back_by + error.class.new("transaction rolled back: #{error.message}", nil, error.error_number, error.sql_state) + end + + # ER_LOCK_DEADLOCK always rolls back the whole transaction, ER_LOCK_WAIT_TIMEOUT only with + # innodb_rollback_on_timeout (otherwise just the statement). + def ends_transaction?(error) + return true if error.error_number == 1213 + return false unless error.error_number == 1205 + + if @rollback_on_timeout.nil? + @rollback_on_timeout = begin + query('SELECT @@innodb_rollback_on_timeout', as: :array).first.first == 1 + rescue ::Mysql2::Error + false + end + end + @rollback_on_timeout + end end # Raised when the query object yielded by #transaction is used after the transaction ended. diff --git a/test/golden/mysql2/default/connection.rbs b/test/golden/mysql2/default/connection.rbs index a9bdc58..e720c06 100644 --- a/test/golden/mysql2/default/connection.rbs +++ b/test/golden/mysql2/default/connection.rbs @@ -2,8 +2,11 @@ module Mysql2Codegen module ConnectionState attr_accessor in_transaction: bool + attr_accessor rolled_back_by: untyped def query: (*untyped) -> untyped def query_running?: () -> bool + def rolled_back_error: () -> untyped + def ends_transaction?: (untyped error) -> bool end class TransactionEnded < ::StandardError diff --git a/test/golden/mysql2/default/my_queries_sql.rb b/test/golden/mysql2/default/my_queries_sql.rb index e2e18f5..f7a6548 100644 --- a/test/golden/mysql2/default/my_queries_sql.rb +++ b/test/golden/mysql2/default/my_queries_sql.rb @@ -67,7 +67,7 @@ def transaction(isolation: nil) failed = true bound&.close begin - client.query(rollback) if started || outermost + client.query(rollback) if (started || outermost) && !client.rolled_back_by rescue ::Mysql2::Error # a deadlock / lock wait timeout already rolled back the transaction: its savepoints are gone; # an interrupted query closed the connection: the server rolls back @@ -82,6 +82,10 @@ def transaction(isolation: nil) # Thread#kill interrupted a query: the client still waits for its result and cannot send # ROLLBACK. Closing it makes the server roll back; #with_client discards it. client.close + elsif client.rolled_back_by + # the server already rolled the whole transaction back (its savepoints too): COMMIT would only + # end the statements run since, so raise instead of reporting a commit + raise client.rolled_back_error if ::Thread.current.status != 'aborting' else commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" begin @@ -99,7 +103,10 @@ def transaction(isolation: nil) end end ensure - client.in_transaction = false if outermost + if outermost + client.in_transaction = false + client.rolled_back_by = nil + end end end end diff --git a/test/golden/mysql2/default/other_sql.rb b/test/golden/mysql2/default/other_sql.rb index e67f869..acdff09 100644 --- a/test/golden/mysql2/default/other_sql.rb +++ b/test/golden/mysql2/default/other_sql.rb @@ -80,7 +80,7 @@ def transaction(isolation: nil) failed = true bound&.close begin - client.query(rollback) if started || outermost + client.query(rollback) if (started || outermost) && !client.rolled_back_by rescue ::Mysql2::Error # a deadlock / lock wait timeout already rolled back the transaction: its savepoints are gone; # an interrupted query closed the connection: the server rolls back @@ -95,6 +95,10 @@ def transaction(isolation: nil) # Thread#kill interrupted a query: the client still waits for its result and cannot send # ROLLBACK. Closing it makes the server roll back; #with_client discards it. client.close + elsif client.rolled_back_by + # the server already rolled the whole transaction back (its savepoints too): COMMIT would only + # end the statements run since, so raise instead of reporting a commit + raise client.rolled_back_error if ::Thread.current.status != 'aborting' else commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" begin @@ -112,7 +116,10 @@ def transaction(isolation: nil) end end ensure - client.in_transaction = false if outermost + if outermost + client.in_transaction = false + client.rolled_back_by = nil + end end end end diff --git a/test/golden/mysql2/default/query_sql.rb b/test/golden/mysql2/default/query_sql.rb index d025567..2f8be2a 100644 --- a/test/golden/mysql2/default/query_sql.rb +++ b/test/golden/mysql2/default/query_sql.rb @@ -602,7 +602,7 @@ def transaction(isolation: nil) failed = true bound&.close begin - client.query(rollback) if started || outermost + client.query(rollback) if (started || outermost) && !client.rolled_back_by rescue ::Mysql2::Error # a deadlock / lock wait timeout already rolled back the transaction: its savepoints are gone; # an interrupted query closed the connection: the server rolls back @@ -617,6 +617,10 @@ def transaction(isolation: nil) # Thread#kill interrupted a query: the client still waits for its result and cannot send # ROLLBACK. Closing it makes the server roll back; #with_client discards it. client.close + elsif client.rolled_back_by + # the server already rolled the whole transaction back (its savepoints too): COMMIT would only + # end the statements run since, so raise instead of reporting a commit + raise client.rolled_back_error if ::Thread.current.status != 'aborting' else commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" begin @@ -634,7 +638,10 @@ def transaction(isolation: nil) end end ensure - client.in_transaction = false if outermost + if outermost + client.in_transaction = false + client.rolled_back_by = nil + end end end end @@ -1648,7 +1655,7 @@ def load_data(client, sql, strict: true) ensure if outermost client.query(done ? 'COMMIT' : 'ROLLBACK') - else + elsif !client.rolled_back_by # a deadlock rolled the whole transaction back, its savepoint too client.query(done ? 'RELEASE SAVEPOINT sqlc_copy' : 'ROLLBACK TO SAVEPOINT sqlc_copy') end end diff --git a/test/golden/mysql2/ruby31_gemfile/connection.rb b/test/golden/mysql2/ruby31_gemfile/connection.rb index e4192c7..4d4b1ec 100644 --- a/test/golden/mysql2/ruby31_gemfile/connection.rb +++ b/test/golden/mysql2/ruby31_gemfile/connection.rb @@ -6,19 +6,49 @@ module Mysql2Codegen # State kept on every Mysql2::Client the first time it is used. module ConnectionState attr_accessor :in_transaction + # The error with which the server rolled back the whole transaction (a deadlock, or a lock wait timeout with + # innodb_rollback_on_timeout): the transaction's later queries raise instead of running in autocommit mode. + attr_accessor :rolled_back_by # Stays true when a Thread#kill interrupts the query (it skips rescue): see #with_client. def query(...) + raise rolled_back_error if in_transaction && rolled_back_by + @query_running = true result = super @query_running = false result + rescue ::Mysql2::Error => e + @query_running = false + self.rolled_back_by ||= e if in_transaction && ends_transaction?(e) + raise rescue ::Exception # rubocop:disable Lint/RescueException @query_running = false raise end def query_running? = @query_running == true + + def rolled_back_error + error = rolled_back_by + error.class.new("transaction rolled back: #{error.message}", nil, error.error_number, error.sql_state) + end + + # ER_LOCK_DEADLOCK always rolls back the whole transaction, ER_LOCK_WAIT_TIMEOUT only with + # innodb_rollback_on_timeout (otherwise just the statement). + def ends_transaction?(error) + return true if error.error_number == 1213 + return false unless error.error_number == 1205 + + if @rollback_on_timeout.nil? + @rollback_on_timeout = begin + query('SELECT @@innodb_rollback_on_timeout', as: :array).first.first == 1 + rescue ::Mysql2::Error + false + end + end + @rollback_on_timeout + end end # Raised when the query object yielded by #transaction is used after the transaction ended. diff --git a/test/golden/mysql2/ruby31_gemfile/connection.rbs b/test/golden/mysql2/ruby31_gemfile/connection.rbs index a9bdc58..e720c06 100644 --- a/test/golden/mysql2/ruby31_gemfile/connection.rbs +++ b/test/golden/mysql2/ruby31_gemfile/connection.rbs @@ -2,8 +2,11 @@ module Mysql2Codegen module ConnectionState attr_accessor in_transaction: bool + attr_accessor rolled_back_by: untyped def query: (*untyped) -> untyped def query_running?: () -> bool + def rolled_back_error: () -> untyped + def ends_transaction?: (untyped error) -> bool end class TransactionEnded < ::StandardError diff --git a/test/golden/mysql2/ruby31_gemfile/my_queries_sql.rb b/test/golden/mysql2/ruby31_gemfile/my_queries_sql.rb index 541bc12..3053a1b 100644 --- a/test/golden/mysql2/ruby31_gemfile/my_queries_sql.rb +++ b/test/golden/mysql2/ruby31_gemfile/my_queries_sql.rb @@ -70,7 +70,7 @@ def transaction(isolation: nil) failed = true bound&.close begin - client.query(rollback) if started || outermost + client.query(rollback) if (started || outermost) && !client.rolled_back_by rescue ::Mysql2::Error # a deadlock / lock wait timeout already rolled back the transaction: its savepoints are gone; # an interrupted query closed the connection: the server rolls back @@ -85,6 +85,10 @@ def transaction(isolation: nil) # Thread#kill interrupted a query: the client still waits for its result and cannot send # ROLLBACK. Closing it makes the server roll back; #with_client discards it. client.close + elsif client.rolled_back_by + # the server already rolled the whole transaction back (its savepoints too): COMMIT would only + # end the statements run since, so raise instead of reporting a commit + raise client.rolled_back_error if ::Thread.current.status != 'aborting' else commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" begin @@ -102,7 +106,10 @@ def transaction(isolation: nil) end end ensure - client.in_transaction = false if outermost + if outermost + client.in_transaction = false + client.rolled_back_by = nil + end end end end diff --git a/test/golden/mysql2/ruby31_gemfile/other_sql.rb b/test/golden/mysql2/ruby31_gemfile/other_sql.rb index 00cb5ea..c79b4f5 100644 --- a/test/golden/mysql2/ruby31_gemfile/other_sql.rb +++ b/test/golden/mysql2/ruby31_gemfile/other_sql.rb @@ -82,7 +82,7 @@ def transaction(isolation: nil) failed = true bound&.close begin - client.query(rollback) if started || outermost + client.query(rollback) if (started || outermost) && !client.rolled_back_by rescue ::Mysql2::Error # a deadlock / lock wait timeout already rolled back the transaction: its savepoints are gone; # an interrupted query closed the connection: the server rolls back @@ -97,6 +97,10 @@ def transaction(isolation: nil) # Thread#kill interrupted a query: the client still waits for its result and cannot send # ROLLBACK. Closing it makes the server roll back; #with_client discards it. client.close + elsif client.rolled_back_by + # the server already rolled the whole transaction back (its savepoints too): COMMIT would only + # end the statements run since, so raise instead of reporting a commit + raise client.rolled_back_error if ::Thread.current.status != 'aborting' else commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" begin @@ -114,7 +118,10 @@ def transaction(isolation: nil) end end ensure - client.in_transaction = false if outermost + if outermost + client.in_transaction = false + client.rolled_back_by = nil + end end end end diff --git a/test/golden/mysql2/ruby31_gemfile/query_sql.rb b/test/golden/mysql2/ruby31_gemfile/query_sql.rb index b8fb3ff..59055f0 100644 --- a/test/golden/mysql2/ruby31_gemfile/query_sql.rb +++ b/test/golden/mysql2/ruby31_gemfile/query_sql.rb @@ -597,7 +597,7 @@ def transaction(isolation: nil) failed = true bound&.close begin - client.query(rollback) if started || outermost + client.query(rollback) if (started || outermost) && !client.rolled_back_by rescue ::Mysql2::Error # a deadlock / lock wait timeout already rolled back the transaction: its savepoints are gone; # an interrupted query closed the connection: the server rolls back @@ -612,6 +612,10 @@ def transaction(isolation: nil) # Thread#kill interrupted a query: the client still waits for its result and cannot send # ROLLBACK. Closing it makes the server roll back; #with_client discards it. client.close + elsif client.rolled_back_by + # the server already rolled the whole transaction back (its savepoints too): COMMIT would only + # end the statements run since, so raise instead of reporting a commit + raise client.rolled_back_error if ::Thread.current.status != 'aborting' else commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" begin @@ -629,7 +633,10 @@ def transaction(isolation: nil) end end ensure - client.in_transaction = false if outermost + if outermost + client.in_transaction = false + client.rolled_back_by = nil + end end end end @@ -1664,7 +1671,7 @@ def load_data(client, sql, strict: true) ensure if outermost client.query(done ? 'COMMIT' : 'ROLLBACK') - else + elsif !client.rolled_back_by # a deadlock rolled the whole transaction back, its savepoint too client.query(done ? 'RELEASE SAVEPOINT sqlc_copy' : 'ROLLBACK TO SAVEPOINT sqlc_copy') end end diff --git a/test/golden/mysql2/ruby32_notypes/connection.rb b/test/golden/mysql2/ruby32_notypes/connection.rb index e4192c7..4d4b1ec 100644 --- a/test/golden/mysql2/ruby32_notypes/connection.rb +++ b/test/golden/mysql2/ruby32_notypes/connection.rb @@ -6,19 +6,49 @@ module Mysql2Codegen # State kept on every Mysql2::Client the first time it is used. module ConnectionState attr_accessor :in_transaction + # The error with which the server rolled back the whole transaction (a deadlock, or a lock wait timeout with + # innodb_rollback_on_timeout): the transaction's later queries raise instead of running in autocommit mode. + attr_accessor :rolled_back_by # Stays true when a Thread#kill interrupts the query (it skips rescue): see #with_client. def query(...) + raise rolled_back_error if in_transaction && rolled_back_by + @query_running = true result = super @query_running = false result + rescue ::Mysql2::Error => e + @query_running = false + self.rolled_back_by ||= e if in_transaction && ends_transaction?(e) + raise rescue ::Exception # rubocop:disable Lint/RescueException @query_running = false raise end def query_running? = @query_running == true + + def rolled_back_error + error = rolled_back_by + error.class.new("transaction rolled back: #{error.message}", nil, error.error_number, error.sql_state) + end + + # ER_LOCK_DEADLOCK always rolls back the whole transaction, ER_LOCK_WAIT_TIMEOUT only with + # innodb_rollback_on_timeout (otherwise just the statement). + def ends_transaction?(error) + return true if error.error_number == 1213 + return false unless error.error_number == 1205 + + if @rollback_on_timeout.nil? + @rollback_on_timeout = begin + query('SELECT @@innodb_rollback_on_timeout', as: :array).first.first == 1 + rescue ::Mysql2::Error + false + end + end + @rollback_on_timeout + end end # Raised when the query object yielded by #transaction is used after the transaction ended. diff --git a/test/golden/mysql2/ruby32_notypes/my_queries_sql.rb b/test/golden/mysql2/ruby32_notypes/my_queries_sql.rb index 541bc12..3053a1b 100644 --- a/test/golden/mysql2/ruby32_notypes/my_queries_sql.rb +++ b/test/golden/mysql2/ruby32_notypes/my_queries_sql.rb @@ -70,7 +70,7 @@ def transaction(isolation: nil) failed = true bound&.close begin - client.query(rollback) if started || outermost + client.query(rollback) if (started || outermost) && !client.rolled_back_by rescue ::Mysql2::Error # a deadlock / lock wait timeout already rolled back the transaction: its savepoints are gone; # an interrupted query closed the connection: the server rolls back @@ -85,6 +85,10 @@ def transaction(isolation: nil) # Thread#kill interrupted a query: the client still waits for its result and cannot send # ROLLBACK. Closing it makes the server roll back; #with_client discards it. client.close + elsif client.rolled_back_by + # the server already rolled the whole transaction back (its savepoints too): COMMIT would only + # end the statements run since, so raise instead of reporting a commit + raise client.rolled_back_error if ::Thread.current.status != 'aborting' else commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" begin @@ -102,7 +106,10 @@ def transaction(isolation: nil) end end ensure - client.in_transaction = false if outermost + if outermost + client.in_transaction = false + client.rolled_back_by = nil + end end end end diff --git a/test/golden/mysql2/ruby32_notypes/other_sql.rb b/test/golden/mysql2/ruby32_notypes/other_sql.rb index ea09242..dbfbec3 100644 --- a/test/golden/mysql2/ruby32_notypes/other_sql.rb +++ b/test/golden/mysql2/ruby32_notypes/other_sql.rb @@ -83,7 +83,7 @@ def transaction(isolation: nil) failed = true bound&.close begin - client.query(rollback) if started || outermost + client.query(rollback) if (started || outermost) && !client.rolled_back_by rescue ::Mysql2::Error # a deadlock / lock wait timeout already rolled back the transaction: its savepoints are gone; # an interrupted query closed the connection: the server rolls back @@ -98,6 +98,10 @@ def transaction(isolation: nil) # Thread#kill interrupted a query: the client still waits for its result and cannot send # ROLLBACK. Closing it makes the server roll back; #with_client discards it. client.close + elsif client.rolled_back_by + # the server already rolled the whole transaction back (its savepoints too): COMMIT would only + # end the statements run since, so raise instead of reporting a commit + raise client.rolled_back_error if ::Thread.current.status != 'aborting' else commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" begin @@ -115,7 +119,10 @@ def transaction(isolation: nil) end end ensure - client.in_transaction = false if outermost + if outermost + client.in_transaction = false + client.rolled_back_by = nil + end end end end diff --git a/test/golden/mysql2/ruby32_notypes/query_sql.rb b/test/golden/mysql2/ruby32_notypes/query_sql.rb index ded568f..e74f50b 100644 --- a/test/golden/mysql2/ruby32_notypes/query_sql.rb +++ b/test/golden/mysql2/ruby32_notypes/query_sql.rb @@ -605,7 +605,7 @@ def transaction(isolation: nil) failed = true bound&.close begin - client.query(rollback) if started || outermost + client.query(rollback) if (started || outermost) && !client.rolled_back_by rescue ::Mysql2::Error # a deadlock / lock wait timeout already rolled back the transaction: its savepoints are gone; # an interrupted query closed the connection: the server rolls back @@ -620,6 +620,10 @@ def transaction(isolation: nil) # Thread#kill interrupted a query: the client still waits for its result and cannot send # ROLLBACK. Closing it makes the server roll back; #with_client discards it. client.close + elsif client.rolled_back_by + # the server already rolled the whole transaction back (its savepoints too): COMMIT would only + # end the statements run since, so raise instead of reporting a commit + raise client.rolled_back_error if ::Thread.current.status != 'aborting' else commit = outermost ? 'COMMIT' : "RELEASE SAVEPOINT #{savepoint}" begin @@ -637,7 +641,10 @@ def transaction(isolation: nil) end end ensure - client.in_transaction = false if outermost + if outermost + client.in_transaction = false + client.rolled_back_by = nil + end end end end @@ -1656,7 +1663,7 @@ def load_data(client, sql, strict: true) ensure if outermost client.query(done ? 'COMMIT' : 'ROLLBACK') - else + elsif !client.rolled_back_by # a deadlock rolled the whole transaction back, its savepoint too client.query(done ? 'RELEASE SAVEPOINT sqlc_copy' : 'ROLLBACK TO SAVEPOINT sqlc_copy') end end diff --git a/tests/end2end_mysql2.rb b/tests/end2end_mysql2.rb index 358c6d6..3a74ddd 100755 --- a/tests/end2end_mysql2.rb +++ b/tests/end2end_mysql2.rb @@ -1052,6 +1052,108 @@ def test_deadlock_in_a_nested_transaction_raises_the_deadlock_error assert_empty @query_sql.list_authors end + # Authors another connection locks in #deadlock_on so that InnoDB picks the other transaction as the victim. + def create_fillers = Array.new(20) { |i| create_author("filler #{i}") } + + # Runs the block (inside a transaction that holds the row `first`) once another connection has locked `second` and + # the (more) `fillers`, and waits for `first`: the block's next lock on `second` is a deadlock that rolls back the + # block's whole transaction (InnoDB rolls back the transaction that changed fewer rows). + def deadlock_on(first, second, fillers) + other = Mysql2::Client.new(mysql2_params) + watcher = Mysql2::Client.new(mysql2_params) + locked = Queue.new + blocker = ::Thread.new do + other.query('BEGIN') + # one primary-key lookup per row: a scan (`id IN (...)` on a small table) would also lock `first` + [second, *fillers].each { |id| other.query("UPDATE authors SET bio = 'other' WHERE id = #{id}") } + locked << true + other.query("UPDATE authors SET bio = 'other' WHERE id = #{first}") + other.query('ROLLBACK') + end + sleep 0.01 while locked.empty? && blocker.alive? + blocker.value unless blocker.alive? # raises what stopped it + deadline = ::Time.now + 10 + waiting = 'SELECT COUNT(*) AS n FROM information_schema.innodb_trx ' \ + "WHERE trx_mysql_thread_id = #{other.thread_id} AND trx_state = 'LOCK WAIT'" + sleep 0.01 until watcher.query(waiting).first['n'] == 1 || ::Time.now > deadline + yield + ensure + blocker&.join(10) + other&.close + watcher&.close + end + + def authors_except_fillers + @query_sql.list_authors.reject { |author| author.name.start_with?('filler') }.map { |author| [author.name, author.bio] } + end + + def test_rescued_deadlock_in_a_transaction_raises_instead_of_committing + first, second = %w[a b].map { |name| create_author(name) } + fillers = create_fillers + after_error = nil + error = assert_raises(Mysql2::Error) do + @query_sql.transaction do |tx| + tx.create_author(name: 'before', bio: nil) + tx.update_author(bio: 'mine', id: first) + deadlock_on(first, second, fillers) do + assert_equal 1213, assert_raises(Mysql2::Error) { tx.update_author(bio: 'mine', id: second) }.error_number + end + # InnoDB already rolled the whole transaction back: this must not run in autocommit mode + after_error = assert_raises(Mysql2::Error) { tx.create_author(name: 'after', bio: nil) } + :committed + end + end + assert_equal [1213, 1213], [error.error_number, after_error.error_number] + assert_equal [['a', nil], ['b', nil]], authors_except_fillers + @query_sql.transaction { |tx| tx.create_author(name: 'later', bio: nil) } + assert_includes authors_except_fillers, ['later', nil] + end + + def test_rescued_deadlock_in_a_nested_transaction_raises_instead_of_committing + first, second = %w[a b].map { |name| create_author(name) } + fillers = create_fillers + inner_error = nil + error = assert_raises(Mysql2::Error) do + @query_sql.transaction do |tx| + tx.create_author(name: 'before', bio: nil) + tx.update_author(bio: 'mine', id: first) + inner_error = assert_raises(Mysql2::Error) do + tx.transaction { |inner| deadlock_on(first, second, fillers) { inner.update_author(bio: 'mine', id: second) } } + end + :committed + end + end + assert_equal [1213, 1213], [inner_error.error_number, error.error_number] + assert_equal [['a', nil], ['b', nil]], authors_except_fillers + end + + def test_rescued_lock_wait_timeout_rolls_back_only_its_statement + first = create_author('a') + queries = QuerySql.connect(mysql2_params.merge('init_command' => 'SET SESSION innodb_lock_wait_timeout = 1'), size: 1) + other = Mysql2::Client.new(mysql2_params) + other.query('BEGIN') + other.query("UPDATE authors SET bio = 'other' WHERE id = #{first}") + whole_transaction = other.query('SELECT @@innodb_rollback_on_timeout AS v').first['v'] == 1 + run = lambda do + queries.transaction do |tx| + tx.create_author(name: 'before', bio: nil) + assert_equal 1205, assert_raises(Mysql2::Error) { tx.update_author(bio: 'mine', id: first) }.error_number + tx.create_author(name: 'after', bio: nil) + :committed + end + end + if whole_transaction # innodb_rollback_on_timeout=ON rolls the whole transaction back, like a deadlock + assert_equal 1205, assert_raises(Mysql2::Error) { run.call }.error_number + assert_equal [['a', nil]], authors_except_fillers + else + assert_equal :committed, run.call + assert_equal [['a', nil], ['after', nil], ['before', nil]], authors_except_fillers + end + ensure + other&.query('ROLLBACK') + other&.close + end + # Models named like Ruby core / library constants (Exception, Data, Thread, ...) used to shadow the constants the # generated code relies on: a raise in a transaction committed, or the code did not even load. CORE_NAMED_MODELS = %i[Exception StandardError Thread Process Data Struct Set String Integer Time Encoding Monitor