aboutsummaryrefslogtreecommitdiffstats
path: root/activerecord/test/cases/hot_compatibility_test.rb
diff options
context:
space:
mode:
authorSam Davies <seivadmas@gmail.com>2015-11-02 18:17:52 -0300
committerSam Davies <seivadmas@gmail.com>2015-11-05 17:39:02 -0300
commit50c53340824de2a8000fd2d5551cbce2603dc34a (patch)
tree77d534cc99f4cdb361f79f598ccf2188e674097a /activerecord/test/cases/hot_compatibility_test.rb
parente670611e6002039231a24d547f9a6e053940fb16 (diff)
downloadrails-50c53340824de2a8000fd2d5551cbce2603dc34a.tar.gz
rails-50c53340824de2a8000fd2d5551cbce2603dc34a.tar.bz2
rails-50c53340824de2a8000fd2d5551cbce2603dc34a.zip
Correctly deallocate prepared statements if we fail inside a transaction
- Addresses issue #12330 Overview ======== Cached postgres prepared statements become invalidated if the schema changes in a way that it affects the returned result. Examples: - adding or removing a column then doing a 'SELECT *' - removing the foo column then doing a 'SELECT bar.foo' In normal operation this isn't a problem, we can rescue the error, deallocate the prepared statement and re-issue the command. However in PostgreSQL transactions, once any command fails, the transaction becomes 'poisoned' and any subsequent commands will raise InFailedSQLTransaction. This includes DEALLOCATE statements, so the default deallocation strategy instead of removing the cached prepared statement instead raises InFailedSQLTransaction. Why this is bad =============== 1. InFailedSQLTransaction is a fairly cryptic error and doesn't communicate any useful information about what has actually gone wrong. 2. In the naive implementation the prepared statement never gets deallocated - it stays alive for the length of the session taking up memory on the postgres server. 3. It is unsafe to retry the transaction because the bad prepared statement is still in the cache and we would see the exact same failure repeated. Solution ======== If we are outside a transaction we can continue to handle these failures gracefully in the usual way. Inside a transaction instead of issuing a DEALLOCATE command that will certainly fail, we now raise ActiveRecord::PreparedStatementCacheExpired. This can be handled further up the stack, notably inside TransactionManager#within_new_transaction. Here we can make sure to first rollback the transaction, then safely issue DEALLOCATE statements to invalidate the rest of the cached prepared statements. This also allows the user (or some gem) the opportunity to catch this error and voluntarily retry the transaction if a schema change causes the prepared statement cache to become invalidated. Because the outdated statement has been deallocated, we can expect the transaction to succeed on the second try.
Diffstat (limited to 'activerecord/test/cases/hot_compatibility_test.rb')
-rw-r--r--activerecord/test/cases/hot_compatibility_test.rb85
1 files changed, 85 insertions, 0 deletions
diff --git a/activerecord/test/cases/hot_compatibility_test.rb b/activerecord/test/cases/hot_compatibility_test.rb
index 5ba9a1029a..e97be4a2fc 100644
--- a/activerecord/test/cases/hot_compatibility_test.rb
+++ b/activerecord/test/cases/hot_compatibility_test.rb
@@ -1,7 +1,9 @@
require 'cases/helper'
+require 'support/connection_helper'
class HotCompatibilityTest < ActiveRecord::TestCase
self.use_transactional_tests = false
+ include ConnectionHelper
setup do
@klass = Class.new(ActiveRecord::Base) do
@@ -51,4 +53,87 @@ class HotCompatibilityTest < ActiveRecord::TestCase
record.reload
assert_equal 'bar', record.foo
end
+
+ if current_adapter?(:PostgreSQLAdapter)
+ test "cleans up after prepared statement failure in a transaction" do
+ with_two_connections do |original_connection, ddl_connection|
+ record = @klass.create! bar: 'bar'
+
+ # prepare the reload statement in a transaction
+ @klass.transaction do
+ record.reload
+ end
+
+ # add a new column
+ ddl_connection.add_column :hot_compatibilities, :baz, :string
+
+ assert_raise(ActiveRecord::PreparedStatementCacheExpired) do
+ @klass.transaction do
+ record.reload
+ end
+ end
+
+ prepared_statement_cache = @klass.connection
+ .instance_variable_get(:@statements)
+ .instance_variable_get(:@cache)[Process.pid]
+
+ assert_empty prepared_statement_cache,
+ "expected prepared statement cache to be empty but it wasn't"
+ end
+ end
+
+ test "cleans up after prepared statement failure in nested transactions" do
+ with_two_connections do |original_connection, ddl_connection|
+ record = @klass.create! bar: 'bar'
+
+ # prepare the reload statement in a transaction
+ @klass.transaction do
+ record.reload
+ end
+
+ # add a new column
+ ddl_connection.add_column :hot_compatibilities, :baz, :string
+
+ assert_raise(ActiveRecord::PreparedStatementCacheExpired) do
+ @klass.transaction do
+ @klass.transaction do
+ @klass.transaction do
+ record.reload
+ end
+ end
+ end
+ end
+
+ prepared_statement_cache = @klass.connection
+ .instance_variable_get(:@statements)
+ .instance_variable_get(:@cache)[Process.pid]
+
+ assert_empty prepared_statement_cache,
+ "expected prepared statement cache to be empty but it wasn't"
+ end
+ end
+ end
+
+ private
+
+ # Rails will automatically clear the prepared statements on the connection
+ # that runs the migration, so we use two connections to simulate what would
+ # actually happen on a production system; we'd have one connection running the
+ # migration from the rake task ("ddl_connection" here), and we'd have another
+ # connection in a web worker.
+ def with_two_connections
+ run_without_connection do |original_connection|
+ ActiveRecord::Base.establish_connection(original_connection.merge(pool_size: 2))
+ begin
+ ddl_connection = ActiveRecord::Base.connection_pool.checkout
+ begin
+ yield original_connection, ddl_connection
+ ensure
+ ActiveRecord::Base.connection_pool.checkin ddl_connection
+ end
+ ensure
+ ActiveRecord::Base.clear_all_connections!
+ end
+ end
+ end
end