query cancellation

Jan UrbaƄski <[email protected]> Sat, 24 Jul 2010 23:06:43 +0200
Newsgroups gmane.comp.python.db.psycopg.devel
Message-ID <[email protected]>
This is a multi-part message in MIME format.
--------------040908050206080107020807
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 7bit

Hi,

here's a patch to support query cancellation via
http://www.postgresql.org/docs/8.4/static/libpq-cancel.html

It's based on top of my rollback_on_exit branch in
http://git.wulczer.org/?p=psycopg2.git, simply because I think the first
branch should be merged in first, but they're mostly independent.

You can cancel both synchronous and asynchronous queries, I haven't
tried green queries, but I think they should work too. I'll add support
for this in the Twisted wrapper soon, hopefully using Deferred cancellation.

Cheers,
Jan

--------------040908050206080107020807
Content-Type: text/x-diff;
 name="0001-Support-query-cancellation.patch"
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
 filename="0001-Support-query-cancellation.patch"

From abd7e98e9c3cd2b888b0bc0d6be5fd2d3786a5ed Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Urba=C5=84ski?= <[email protected]>
Date: Sat, 24 Jul 2010 23:01:27 +0200
Subject: [PATCH] Support query cancellation.

Add a cancel() method do the connection object that will interrupt
the current query using the libpq PQcancel() function.
---
 doc/src/connection.rst    |   20 ++++++++++
 psycopg/connection.h      |    1 +
 psycopg/connection_int.c  |   18 +++++++++
 psycopg/connection_type.c |   34 +++++++++++++++++
 tests/__init__.py         |    2 +
 tests/test_cancel.py      |   89 +++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 164 insertions(+), 0 deletions(-)
 create mode 100644 tests/test_cancel.py

diff --git a/doc/src/connection.rst b/doc/src/connection.rst
index 048bbcd..0685ee2 100644
--- a/doc/src/connection.rst
+++ b/doc/src/connection.rst
@@ -94,6 +94,26 @@ The ``connection`` class
         (0) or closed (1).
 
 
+    .. method:: cancel
+
+        Cancel the current database operation.
+
+        The method interrupts the processing of the current operation. If no
+        query is being executed, it does nothing. You can call this function
+        from a different thread than the one currently executing a database
+        operation, for instance if you want to cancel a long running query if a
+        button is pushed in the UI. Interrupting query execution will cause the
+        cancelled method to raise a
+        `~psycopg2.extensions.QueryCanceledError`. Note that the termination
+        of the query is not guaranteed to succeed: see the documentation for
+        |PQcancel|_.
+
+        .. |PQcancel| replace:: `!PQcancel()`
+        .. _PQcancel: http://www.postgresql.org/docs/8.4/static/libpq-cancel.html#AEN34765
+
+        .. versionadded:: 2.2.3
+
+
     .. method:: reset
 
         Reset the connection to the default.
diff --git a/psycopg/connection.h b/psycopg/connection.h
index eef4275..bfd2a5f 100644
--- a/psycopg/connection.h
+++ b/psycopg/connection.h
@@ -93,6 +93,7 @@ typedef struct {
     int server_version;       /* server version */
 
     PGconn *pgconn;           /* the postgresql connection */
+    PGcancel *cancel;         /* the cancellation structure */
 
     PyObject *async_cursor;   /* a cursor executing an asynchronous query */
     int async_status;         /* asynchronous execution status */
diff --git a/psycopg/connection_int.c b/psycopg/connection_int.c
index a69ae7c..f92d67c 100644
--- a/psycopg/connection_int.c
+++ b/psycopg/connection_int.c
@@ -249,6 +249,12 @@ conn_get_server_version(PGconn *pgconn)
     return (int)PQserverVersion(pgconn);
 }
 
+PGcancel *
+conn_get_cancel(PGconn *pgconn)
+{
+    return PQgetCancel(pgconn);
+}
+
 
 /* conn_setup - setup and read basic information about the connection */
 
@@ -261,6 +267,11 @@ conn_setup(connectionObject *self, PGconn *pgconn)
     self->equote = conn_get_standard_conforming_strings(pgconn);
     self->server_version = conn_get_server_version(pgconn);
     self->protocol = conn_get_protocol_version(self->pgconn);
+    self->cancel = conn_get_cancel(self->pgconn);
+    if (self->cancel == NULL) {
+        PyErr_SetString(OperationalError, "can't get cancellation key");
+        return -1;
+    }
 
     Py_BEGIN_ALLOW_THREADS;
     pthread_mutex_lock(&self->lock);
@@ -602,6 +613,11 @@ _conn_poll_setup_async(connectionObject *self)
         self->equote = conn_get_standard_conforming_strings(self->pgconn);
         self->protocol = conn_get_protocol_version(self->pgconn);
         self->server_version = conn_get_server_version(self->pgconn);
+        self->cancel = conn_get_cancel(self->pgconn);
+        if (self->cancel == NULL) {
+            PyErr_SetString(OperationalError, "can't get cancellation key");
+            break;
+        }
 
         /* asynchronous connections always use isolation level 0, the user is
          * expected to manage the transactions himself, by sending
@@ -761,8 +777,10 @@ conn_close(connectionObject *self)
 
     if (self->pgconn) {
         PQfinish(self->pgconn);
+        PQfreeCancel(self->cancel);
         Dprintf("conn_close: PQfinish called");
         self->pgconn = NULL;
+        self->cancel = NULL;
    }
    
     pthread_mutex_unlock(&self->lock);
diff --git a/psycopg/connection_type.c b/psycopg/connection_type.c
index afb924e..c9f3efa 100644
--- a/psycopg/connection_type.c
+++ b/psycopg/connection_type.c
@@ -477,6 +477,37 @@ psyco_conn_isexecuting(connectionObject *self)
     return Py_False;
 }
 
+
+/* extension: cancel - cancel the current operation */
+
+#define psyco_conn_cancel_doc                           \
+"cancel() -- cancel the current operation"
+
+static PyObject *
+psyco_conn_cancel(connectionObject *self)
+{
+    char errbuf[256];
+
+    EXC_IF_CONN_CLOSED(self);
+
+    /* do not allow cancellation while the connection is being built */
+    Dprintf("psyco_conn_cancel: cancelling with key %p", self->cancel);
+    if (self->status != CONN_STATUS_READY &&
+        self->status != CONN_STATUS_BEGIN) {
+        PyErr_SetString(OperationalError,
+                        "asynchronous connection attempt underway");
+        return NULL;
+    }
+
+    if (PQcancel(self->cancel, errbuf, sizeof(errbuf)) == 0) {
+        Dprintf("psyco_conn_cancel: cancelling failed: %s", errbuf);
+        PyErr_SetString(OperationalError, errbuf);
+        return NULL;
+    }
+    Py_INCREF(Py_None);
+    return Py_None;
+}
+
 #endif  /* PSYCOPG_EXTENSIONS */
 
 
@@ -515,6 +546,8 @@ static struct PyMethodDef connectionObject_methods[] = {
      METH_NOARGS, psyco_conn_fileno_doc},
     {"isexecuting", (PyCFunction)psyco_conn_isexecuting,
      METH_NOARGS, psyco_conn_isexecuting_doc},
+    {"cancel", (PyCFunction)psyco_conn_cancel,
+     METH_NOARGS, psyco_conn_cancel_doc},
 #endif
     {NULL}
 };
@@ -595,6 +628,7 @@ connection_setup(connectionObject *self, const char *dsn, long int async)
     self->async_cursor = NULL;
     self->async_status = ASYNC_DONE;
     self->pgconn = NULL;
+    self->cancel = NULL;
     self->mark = 0;
     self->string_types = PyDict_New();
     self->binary_types = PyDict_New();
diff --git a/tests/__init__.py b/tests/__init__.py
index 1c8f6f2..890c797 100755
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -46,6 +46,7 @@ import test_copy
 import test_notify
 import test_async
 import test_green
+import test_cancel
 
 def test_suite():
     suite = unittest.TestSuite()
@@ -70,6 +71,7 @@ def test_suite():
     suite.addTest(test_notify.test_suite())
     suite.addTest(test_async.test_suite())
     suite.addTest(test_green.test_suite())
+    suite.addTest(test_cancel.test_suite())
     return suite
 
 if __name__ == '__main__':
diff --git a/tests/test_cancel.py b/tests/test_cancel.py
new file mode 100644
index 0000000..746b211
--- /dev/null
+++ b/tests/test_cancel.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python
+
+import time
+import threading
+import unittest
+
+import tests
+import psycopg2
+import psycopg2.extensions
+from psycopg2 import extras
+
+
+class CancelTests(unittest.TestCase):
+
+    def setUp(self):
+        self.conn = psycopg2.connect(tests.dsn)
+        cur = self.conn.cursor()
+        cur.execute('''
+            CREATE TEMPORARY TABLE table1 (
+              id int PRIMARY KEY
+            )''')
+        self.conn.commit()
+
+    def tearDown(self):
+        self.conn.close()
+
+    def test_empty_cancel(self):
+        self.conn.cancel()
+
+    def test_cancel(self):
+        errors = []
+
+        def neverending(conn):
+            cur = conn.cursor()
+            try:
+                self.assertRaises(psycopg2.extensions.QueryCanceledError,
+                                  cur.execute, "select pg_sleep(10000)")
+            # make sure the connection still works
+                conn.rollback()
+                cur.execute("select 1")
+                self.assertEqual(cur.fetchall(), [(1, )])
+            except Exception, e:
+                errors.append(e)
+                raise
+
+        def canceller(conn):
+            cur = conn.cursor()
+            try:
+                conn.cancel()
+            except Exception, e:
+                errors.append(e)
+                raise
+
+        thread1 = threading.Thread(target=neverending, args=(self.conn, ))
+        # wait a bit to make sure that the other thread is already in
+        # pg_sleep -- ugly and racy, but the chances are ridiculously low
+        thread2 = threading.Timer(0.3, canceller, args=(self.conn, ))
+        thread1.start()
+        thread2.start()
+        thread1.join()
+        thread2.join()
+
+        self.assertEqual(errors, [])
+
+    def test_async_cancel(self):
+        async_conn = psycopg2.connect(tests.dsn, async=True)
+        self.assertRaises(psycopg2.OperationalError, async_conn.cancel)
+        extras.wait_select(async_conn)
+        cur = async_conn.cursor()
+        cur.execute("select pg_sleep(10000)")
+        self.assertTrue(async_conn.isexecuting())
+        async_conn.cancel()
+        self.assertRaises(psycopg2.extensions.QueryCanceledError,
+                          extras.wait_select, async_conn)
+        cur.execute("select 1")
+        extras.wait_select(async_conn)
+        self.assertEqual(cur.fetchall(), [(1, )])
+
+    def test_async_connection_cancel(self):
+        async_conn = psycopg2.connect(tests.dsn, async=True)
+        async_conn.close()
+        self.assertTrue(async_conn.closed)
+
+
+def test_suite():
+    return unittest.TestLoader().loadTestsFromName(__name__)
+
+if __name__ == "__main__":
+    unittest.main()
-- 
1.7.1


--------------040908050206080107020807
Content-Type: text/plain; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Content-Disposition: inline

_______________________________________________
Psycopg mailing list
Psycopg-IAPFreCvJWPBWskQ1e/[email protected]
http://lists.initd.org/mailman/listinfo/psycopg

--------------040908050206080107020807--