From 0e2cd3d749dadcdb5027d48399aea02ef74815f3 Mon Sep 17 00:00:00 2001
From: Ryuta Kamizono <kamipo@gmail.com>
Date: Mon, 27 Nov 2017 11:54:59 +0900
Subject: Add new error class `QueryCanceled` which will be raised when
 canceling statement due to user request (#31235)

This changes `StatementTimeout` to `QueryCanceled` for PostgreSQL.

In MySQL, errno 1317 (`ER_QUERY_INTERRUPTED`) is only used when the
query is manually cancelled.

But in PostgreSQL, `QUERY_CANCELED` error code (57014) which is used
`StatementTimeout` is also used when the both case. And, we can not tell
which reason happened.

So I decided to introduce new error class `QueryCanceled` closer to the
error code name.
---
 activerecord/CHANGELOG.md                          |  5 ++++
 .../connection_adapters/abstract_mysql_adapter.rb  |  3 +++
 .../connection_adapters/postgresql_adapter.rb      |  2 +-
 activerecord/lib/active_record/errors.rb           |  4 +++
 .../test/cases/adapters/mysql2/transaction_test.rb | 27 +++++++++++++++++++
 .../cases/adapters/postgresql/transaction_test.rb  | 31 ++++++++++++++++++++--
 6 files changed, 69 insertions(+), 3 deletions(-)

(limited to 'activerecord')

diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md
index ccd2aa90e1..40a26d98e1 100644
--- a/activerecord/CHANGELOG.md
+++ b/activerecord/CHANGELOG.md
@@ -1,3 +1,8 @@
+*   Add new error class `QueryCanceled` which will be raised
+    when canceling statement due to user request.
+
+    *Ryuta Kamizono*
+
 *   Add `#up_only` to database migrations for code that is only relevant when
     migrating up, e.g. populating a new column.
 
diff --git a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb
index 77ccac54bf..ede8a9c1e2 100644
--- a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb
+++ b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb
@@ -635,6 +635,7 @@ module ActiveRecord
         ER_CANNOT_ADD_FOREIGN   = 1215
         ER_CANNOT_CREATE_TABLE  = 1005
         ER_LOCK_WAIT_TIMEOUT    = 1205
+        ER_QUERY_INTERRUPTED    = 1317
         ER_QUERY_TIMEOUT        = 3024
 
         def translate_exception(exception, message)
@@ -663,6 +664,8 @@ module ActiveRecord
             LockWaitTimeout.new(message)
           when ER_QUERY_TIMEOUT
             StatementTimeout.new(message)
+          when ER_QUERY_INTERRUPTED
+            QueryCanceled.new(message)
           else
             super
           end
diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
index 489feadb00..7b27f6b7a0 100644
--- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
+++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
@@ -420,7 +420,7 @@ module ActiveRecord
           when LOCK_NOT_AVAILABLE
             LockWaitTimeout.new(message)
           when QUERY_CANCELED
-            StatementTimeout.new(message)
+            QueryCanceled.new(message)
           else
             super
           end
diff --git a/activerecord/lib/active_record/errors.rb b/activerecord/lib/active_record/errors.rb
index 89e777ffc3..efcbd44776 100644
--- a/activerecord/lib/active_record/errors.rb
+++ b/activerecord/lib/active_record/errors.rb
@@ -343,6 +343,10 @@ module ActiveRecord
   class StatementTimeout < StatementInvalid
   end
 
+  # QueryCanceled will be raised when canceling statement due to user request.
+  class QueryCanceled < StatementInvalid
+  end
+
   # UnknownAttributeReference is raised when an unknown and potentially unsafe
   # value is passed to a query method when allow_unsafe_raw_sql is set to
   # :disabled. For example, passing a non column name value to a relation's
diff --git a/activerecord/test/cases/adapters/mysql2/transaction_test.rb b/activerecord/test/cases/adapters/mysql2/transaction_test.rb
index da4b73a0da..cb183cc54c 100644
--- a/activerecord/test/cases/adapters/mysql2/transaction_test.rb
+++ b/activerecord/test/cases/adapters/mysql2/transaction_test.rb
@@ -116,5 +116,32 @@ module ActiveRecord
         end
       end
     end
+
+    test "raises QueryCanceled when canceling statement due to user request" do
+      assert_raises(ActiveRecord::QueryCanceled) do
+        s = Sample.create!(value: 1)
+        latch = Concurrent::CountDownLatch.new
+
+        thread = Thread.new do
+          Sample.transaction do
+            Sample.lock.find(s.id)
+            latch.count_down
+            sleep(0.5)
+            conn = Sample.connection
+            pid = conn.query_value("SELECT id FROM information_schema.processlist WHERE info LIKE '% FOR UPDATE'")
+            conn.execute("KILL QUERY #{pid}")
+          end
+        end
+
+        begin
+          Sample.transaction do
+            latch.wait
+            Sample.lock.find(s.id)
+          end
+        ensure
+          thread.join
+        end
+      end
+    end
   end
 end
diff --git a/activerecord/test/cases/adapters/postgresql/transaction_test.rb b/activerecord/test/cases/adapters/postgresql/transaction_test.rb
index ebba0f0c1c..c24dfeb345 100644
--- a/activerecord/test/cases/adapters/postgresql/transaction_test.rb
+++ b/activerecord/test/cases/adapters/postgresql/transaction_test.rb
@@ -120,8 +120,8 @@ module ActiveRecord
       end
     end
 
-    test "raises StatementTimeout when statement timeout exceeded" do
-      assert_raises(ActiveRecord::StatementTimeout) do
+    test "raises QueryCanceled when statement timeout exceeded" do
+      assert_raises(ActiveRecord::QueryCanceled) do
         s = Sample.create!(value: 1)
         latch1 = Concurrent::CountDownLatch.new
         latch2 = Concurrent::CountDownLatch.new
@@ -148,6 +148,33 @@ module ActiveRecord
       end
     end
 
+    test "raises QueryCanceled when canceling statement due to user request" do
+      assert_raises(ActiveRecord::QueryCanceled) do
+        s = Sample.create!(value: 1)
+        latch = Concurrent::CountDownLatch.new
+
+        thread = Thread.new do
+          Sample.transaction do
+            Sample.lock.find(s.id)
+            latch.count_down
+            sleep(0.5)
+            conn = Sample.connection
+            pid = conn.query_value("SELECT pid FROM pg_stat_activity WHERE query LIKE '% FOR UPDATE'")
+            conn.execute("SELECT pg_cancel_backend(#{pid})")
+          end
+        end
+
+        begin
+          Sample.transaction do
+            latch.wait
+            Sample.lock.find(s.id)
+          end
+        ensure
+          thread.join
+        end
+      end
+    end
+
     private
 
       def with_warning_suppression
-- 
cgit v1.2.3