On 17/09/2007, James Henstridge <[EMAIL PROTECTED]> wrote: > Hi, > > I've updated my database disconnect branch, and think it is just about > ready. The branch is attached to bug 94986, and the branch is at r178 > (it looks like Launchpad hasn't yet published the latest changes).
As the branch still hasn't published, here's bundle of the change made against trunk. James.
# Bazaar merge directive format 2 (Bazaar 0.90) # revision_id: [EMAIL PROTECTED] # hhb42uxgysux6lcl # target_branch: http://bazaar.launchpad.net/%7Estorm/storm/trunk/ # testament_sha1: 6dbb0be16a0d387415b4c85a456612e907970a77 # timestamp: 2007-09-17 21:01:08 +0800 # source_branch: sftp://devpad.canonical.com/code/jamesh/storm\ # /reconnect # base_revision_id: [EMAIL PROTECTED] # fvghwn1552shwijz # # Begin patch === added file 'tests/databases/proxy.py' --- tests/databases/proxy.py 1970-01-01 00:00:00 +0000 +++ tests/databases/proxy.py 2007-09-17 08:17:13 +0000 @@ -0,0 +1,105 @@ + +import os +import select +import socket +import SocketServer +import threading + +TIMEOUT = 0.1 + + +class ProxyRequestHandler(SocketServer.BaseRequestHandler): + """A request handler that proxies traffic to another TCP port.""" + + def __init__(self, request, client_address, server): + self._generation = server._generation + SocketServer.BaseRequestHandler.__init__( + self, request, client_address, server) + + def handle(self): + dst = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + dst.connect(self.server.proxy_dest) + + readers = [self.request, dst] + while readers: + rlist, wlist, xlist = select.select(readers, [], [], TIMEOUT) + + # If the server generation has been incremented, close the + # connection. + if self._generation != self.server._generation: + return + + if self.request in rlist: + chunk = os.read(self.request.fileno(), 1024) + dst.send(chunk) + if chunk == '': + readers.remove(self.request) + dst.shutdown(socket.SHUT_WR) + + if dst in rlist: + chunk = os.read(dst.fileno(), 1024) + self.request.send(chunk) + if chunk == '': + readers.remove(dst) + self.request.shutdown(socket.SHUT_WR) + +class ProxyTCPServer(SocketServer.ThreadingTCPServer): + + allow_reuse_address = True + + def __init__(self, proxy_dest): + SocketServer.ThreadingTCPServer.__init__( + self, ('127.0.0.1', 0), ProxyRequestHandler) + self.proxy_dest = proxy_dest + self._start_lock = threading.Lock() + self._thread = None + self._generation = 0 + self._running = False + self.start() + + def __del__(self): + self.close() + + def close(self): + if self._running: + self.stop() + + def start(self): + assert not self._running, 'Server should not be running' + self._thread = threading.Thread(target=self._run) + self._thread.setDaemon(True) + + self._running = True + self._start_lock.acquire() + self._thread.start() + # Wait for server to start + self._start_lock.acquire() + self._start_lock.release() + + def _run(self): + self.server_activate() + self.socket.settimeout(TIMEOUT) + self._start_lock.release() + while self._running: + try: + self.handle_request() + except socket.timeout: + pass + + def stop(self): + assert self._running, 'Server should be running' + # Increment server generation, and wait for thread to stop. + self._generation += 1 + self._running = False + self._thread.join() + + # Recreate socket, to kill listen queue. As we've allowed + # address reuse, this should work. + self.socket.close() + self.socket = socket.socket(self.address_family, + self.socket_type) + self.server_bind() + + def restart(self): + self.stop() + self.start() === modified file 'storm/database.py' --- storm/database.py 2007-08-08 19:01:45 +0000 +++ storm/database.py 2007-09-17 08:52:24 +0000 @@ -27,7 +27,7 @@ from storm.expr import Expr, State, compile from storm.variables import Variable -from storm.exceptions import ClosedError +from storm.exceptions import ClosedError, DatabaseError, DisconnectionError from storm.uri import URI import storm @@ -47,6 +47,7 @@ def __init__(self, connection, raw_cursor): self._connection = connection # Ensures deallocation order. self._raw_cursor = raw_cursor + self._generation = connection._generation if raw_cursor.arraysize == 1: # Default of 1 is silly. self._raw_cursor.arraysize = 10 @@ -74,7 +75,8 @@ @return: A converted row or None, if no data is left. """ - row = self._raw_cursor.fetchone() + assert self._generation == self._connection._generation + row = self._connection._check_disconnect(self._raw_cursor.fetchone) if row is not None: return tuple(self.from_database(row)) return None @@ -85,7 +87,8 @@ The results will be converted to an appropriate format via L{from_database}. """ - result = self._raw_cursor.fetchall() + assert self._generation == self._connection._generation + result = self._connection._check_disconnect(self._raw_cursor.fetchall) if result: return [tuple(self.from_database(row)) for row in result] return result @@ -97,7 +100,8 @@ L{from_database}. """ while True: - results = self._raw_cursor.fetchmany() + results = self._connection._check_disconnect( + self._raw_cursor.fetchmany) if not results: break for result in results: @@ -147,10 +151,12 @@ compile = compile _closed = False + _is_dead = False + _generation = 0 - def __init__(self, database, raw_connection): + def __init__(self, database): self._database = database # Ensures deallocation order. - self._raw_connection = raw_connection + self._raw_connection = self._database._connect() def __del__(self): """Close the connection.""" @@ -172,6 +178,7 @@ """ if self._closed: raise ClosedError("Connection is closed") + self._ensure_connected() if isinstance(statement, Expr): if params is not None: raise ValueError("Can't pass parameters with expressions") @@ -179,9 +186,10 @@ statement = self.compile(statement, state) params = state.parameters statement = convert_param_marks(statement, "?", self.param_mark) - raw_cursor = self.raw_execute(statement, params) + raw_cursor = self._check_disconnect( + self.raw_execute, statement, params) if noresult: - raw_cursor.close() + self._check_disconnect(raw_cursor.close) return None return self.result_factory(self, raw_cursor) @@ -194,11 +202,20 @@ def commit(self): """Commit the connection.""" - self._raw_connection.commit() + self._ensure_connected() + self._check_disconnect(self._raw_connection.commit) def rollback(self): """Rollback the connection.""" - self._raw_connection.rollback() + if self._raw_connection is not None: + try: + self._raw_connection.rollback() + except DatabaseError, exc: + if self._is_disconnection(exc): + self._raw_connection = None + else: + raise + self._is_dead = False @staticmethod def to_database(params): @@ -247,6 +264,48 @@ raw_cursor.execute(statement, params) return raw_cursor + def _ensure_connected(self): + """Ensure that we are connected to the database. + + If the connection is marked as dead, or if we can't reconnect, + then raise DisconnectionError. + + If we need to reconnect, the connection generation number is + incremented. + """ + if self._is_dead: + raise DisconnectionError('Already disconnected') + if self._raw_connection is not None: + return + try: + self._raw_connection = self._database._connect() + self._generation += 1 + except DatabaseError, exc: + self._is_dead = True + self._raw_connection = None + raise DisconnectionError(str(exc)) + + def _is_disconnection(self, exc): + """Check whether the given exception value represents a + database disconnection. + + This should be overridden by backends to detect whichever + exception values are used to represent this condition. + """ + return False + + def _check_disconnect(self, function, *args, **kwargs): + """Run the given function, checking for database disconnections.""" + try: + return function(*args, **kwargs) + except DatabaseError, exc: + if self._is_disconnection(exc): + self._is_dead = True + self._raw_connection = None + raise DisconnectionError(str(exc)) + else: + raise + def preset_primary_key(self, primary_columns, primary_variables): """Process primary variables before an insert happens. @@ -261,8 +320,7 @@ This should be subclassed for individual database backends. @cvar connection_factory: A callable which will take this database - and a raw connection and should return an instance of - L{Connection}. + and should return an instance of L{Connection}. """ connection_factory = Connection @@ -270,12 +328,22 @@ def connect(self): """Create a connection to the database. - This should be overriden in subclasses to do any - database-specific connection setup. It should call - C{self.connection_factory} to allow for ease of customization. + It calls C{self.connection_factory} to allow for ease of + customization. @return: An instance of L{Connection}. """ + return self.connection_factory(self) + + def _connect(self): + """Create a raw database connection. + + This is used by L{Connection} objects to connect to the + database. It should be overriden in subclasses to do any + database-specific connection setup. + + @return: A DB-API connection object. + """ raise NotImplementedError === modified file 'storm/databases/mysql.py' --- storm/databases/mysql.py 2007-08-08 15:02:34 +0000 +++ storm/databases/mysql.py 2007-09-05 07:27:57 +0000 @@ -118,9 +118,9 @@ self._connect_kwargs["conv"] = self._converters self._connect_kwargs["use_unicode"] = True - def connect(self): + def _connect(self): raw_connection = MySQLdb.connect(**self._connect_kwargs) - return self.connection_factory(self, raw_connection) + return raw_connection create_from_uri = MySQL === modified file 'storm/databases/postgres.py' --- storm/databases/postgres.py 2007-08-08 15:02:34 +0000 +++ storm/databases/postgres.py 2007-09-17 08:17:13 +0000 @@ -166,6 +166,17 @@ else: yield param + def _is_disconnection(self, exc): + msg = exc.args[0] + # XXX: 2007-09-17 jamesh + # I have no idea why I am seeing the last exception message + # after upgrading to Gutsy. + return (msg.startswith('server closed the connection unexpectedly') or + msg.startswith('could not connect to server') or + msg.startswith('no connection to the server') or + msg.startswith('connection not open') or + msg.startswith('losed the connection unexpectedly')) + class Postgres(Database): @@ -176,12 +187,12 @@ raise DatabaseModuleError("'psycopg2' module not found") self._dsn = make_dsn(uri) - def connect(self): + def _connect(self): raw_connection = psycopg2.connect(self._dsn) raw_connection.set_client_encoding("UTF8") raw_connection.set_isolation_level( psycopg2.extensions.ISOLATION_LEVEL_SERIALIZABLE) - return self.connection_factory(self, raw_connection) + return raw_connection create_from_uri = Postgres === modified file 'storm/databases/sqlite.py' --- storm/databases/sqlite.py 2007-08-08 15:02:34 +0000 +++ storm/databases/sqlite.py 2007-09-05 07:27:57 +0000 @@ -167,11 +167,11 @@ self._filename = uri.database or ":memory:" self._timeout = float(uri.options.get("timeout", 5)) - def connect(self): + def _connect(self): # See the story at the end to understand why we set isolation_level. raw_connection = sqlite.connect(self._filename, timeout=self._timeout, isolation_level=None) - return self.connection_factory(self, raw_connection) + return raw_connection create_from_uri = SQLite === modified file 'storm/exceptions.py' --- storm/exceptions.py 2007-08-07 18:36:04 +0000 +++ storm/exceptions.py 2007-09-07 03:48:00 +0000 @@ -50,6 +50,9 @@ class ClosedError(StormError): pass +class DisconnectionError(StormError): + pass + class FeatureError(StormError): pass === modified file 'tests/database.py' --- tests/database.py 2007-08-08 15:02:34 +0000 +++ tests/database.py 2007-09-17 08:52:24 +0000 @@ -90,6 +90,15 @@ return result +class FakeConnection(object): + + def __init__(self): + self._generation = 0 + + def _check_disconnect(self, _function, *args, **kwargs): + return _function(*args, **kwargs) + + class DatabaseTest(TestHelper): def setUp(self): @@ -107,7 +116,8 @@ self.executed = [] self.database = Database() self.raw_connection = RawConnection(self.executed) - self.connection = Connection(self.database, self.raw_connection) + self.database._connect = lambda: self.raw_connection + self.connection = Connection(self.database) def test_execute(self): result = self.connection.execute("something") @@ -131,7 +141,7 @@ def test_execute_convert_param_style(self): class MyConnection(Connection): param_mark = "%s" - connection = MyConnection(self.database, RawConnection(self.executed)) + connection = MyConnection(self.database) result = connection.execute("'?' ? '?' ? '?'") self.assertEquals(self.executed, [("'?' %s '?' %s '?'", marker)]) @@ -203,7 +213,7 @@ TestHelper.setUp(self) self.executed = [] self.raw_cursor = RawCursor(executed=self.executed) - self.result = Result(None, self.raw_cursor) + self.result = Result(FakeConnection(), self.raw_cursor) def test_get_one(self): self.assertEquals(self.result.get_one(), ("fetchone0",)) @@ -217,7 +227,7 @@ self.assertEquals(self.result.get_all(), []) def test_iter(self): - result = Result(None, RawCursor(2)) + result = Result(FakeConnection(), RawCursor(2)) self.assertEquals([item for item in result], [("fetchmany0",), ("fetchmany1",), ("fetchmany2",), ("fetchmany3",), ("fetchmany4",)]) @@ -256,13 +266,13 @@ """When the arraysize is 1, change it to a better value.""" raw_cursor = RawCursor() self.assertEquals(raw_cursor.arraysize, 1) - result = Result(None, raw_cursor) + result = Result(FakeConnection(), raw_cursor) self.assertEquals(raw_cursor.arraysize, 10) def test_preserve_arraysize(self): """When the arraysize is not 1, preserve it.""" raw_cursor = RawCursor(arraysize=123) - result = Result(None, raw_cursor) + result = Result(FakeConnection(), raw_cursor) self.assertEquals(raw_cursor.arraysize, 123) === modified file 'tests/databases/base.py' --- tests/databases/base.py 2007-08-08 15:02:34 +0000 +++ tests/databases/base.py 2007-09-17 08:17:13 +0000 @@ -21,7 +21,6 @@ # from datetime import datetime, date, time import cPickle as pickle -import thread import shutil import sys import os @@ -32,7 +31,8 @@ DecimalVariable) from storm.variables import DateTimeVariable, DateVariable, TimeVariable from storm.database import * -from storm.exceptions import DatabaseModuleError, OperationalError +from storm.exceptions import ( + DatabaseModuleError, DisconnectionError, OperationalError) from tests.helper import MakePath @@ -395,3 +395,86 @@ del sys.modules["_fake_"] sys.modules.update(dbapi_modules) + + +class DatabaseDisconnectionTest(object): + + def setUp(self): + super(DatabaseDisconnectionTest, self).setUp() + self.create_database_and_proxy() + self.create_connection() + + def tearDown(self): + self.drop_database() + self.proxy.close() + super(DatabaseDisconnectionTest, self).tearDown() + + def create_database_and_proxy(self): + """Set up the TCP proxy and database object. + + The TCP proxy should forward requests on to the database. The + database object should point at the TCP proxy. + """ + raise NotImplementedError + + def create_connection(self): + self.connection = self.database.connect() + + def drop_database(self): + pass + + def test_proxy_works(self): + # Ensure that we can talk to the database through the proxy. + result = self.connection.execute("SELECT 1") + self.assertEqual(result.get_one(), (1,)) + + def test_catch_disconnect_on_execute(self): + # Test that database disconnections get caught on execute(). + result = self.connection.execute("SELECT 1") + self.assertTrue(result.get_one()) + self.proxy.restart() + self.assertRaises(DisconnectionError, + self.connection.execute, "SELECT 1") + + def test_catch_disconnect_on_commit(self): + # Test that database disconnections get caught on commit(). + result = self.connection.execute("SELECT 1") + self.assertTrue(result.get_one()) + self.proxy.restart() + self.assertRaises(DisconnectionError, self.connection.commit) + + def test_connection_stays_disconnected_in_transaction(self): + # Test that subsequent operations in the transaction also + # raise DisconnectionError. + result = self.connection.execute("SELECT 1") + self.assertTrue(result.get_one()) + self.proxy.restart() + self.assertRaises(DisconnectionError, + self.connection.execute, "SELECT 1") + self.assertRaises(DisconnectionError, + self.connection.execute, "SELECT 1") + + def test_reconnect_after_rollback(self): + # Test that we reconnect after rolling back the connection. + result = self.connection.execute("SELECT 1") + self.assertTrue(result.get_one()) + self.proxy.restart() + self.assertRaises(DisconnectionError, + self.connection.execute, "SELECT 1") + self.connection.rollback() + result = self.connection.execute("SELECT 1") + self.assertTrue(result.get_one()) + + def test_catch_disconnect_on_reconnect(self): + # Test that we raise DisconnectionError if unable to reconnect + # on a new transaction. + result = self.connection.execute("SELECT 1") + self.assertTrue(result.get_one()) + self.proxy.stop() + self.assertRaises(DisconnectionError, + self.connection.execute, "SELECT 1") + # Rollback the connection, but because the proxy is still + # down, we get a DisconnectionError again. + self.connection.rollback() + self.assertRaises(DisconnectionError, + self.connection.execute, "SELECT 1") === modified file 'tests/databases/postgres.py' --- tests/databases/postgres.py 2007-08-07 23:15:09 +0000 +++ tests/databases/postgres.py 2007-09-17 08:17:13 +0000 @@ -28,7 +28,9 @@ from storm.variables import ListVariable, IntVariable, Variable from storm.expr import Union, Select, Alias, SQLRaw, State, Sequence -from tests.databases.base import DatabaseTest, UnsupportedDatabaseTest +from tests.databases.base import ( + DatabaseTest, DatabaseDisconnectionTest, UnsupportedDatabaseTest) +from tests.databases.proxy import ProxyTCPServer from tests.helper import TestHelper, MakePath @@ -215,3 +217,16 @@ dbapi_module_names = ["psycopg2"] db_module_name = "postgres" + + +class PostgresDisconnectionTest(DatabaseDisconnectionTest, TestHelper): + + def is_supported(self): + return bool(os.environ.get("STORM_POSTGRES_URI")) + + def create_database_and_proxy(self): + uri = URI(os.environ["STORM_POSTGRES_URI"]) + self.proxy = ProxyTCPServer((uri.host or '127.0.0.1', + uri.port or 5432)) + uri.host, uri.port = self.proxy.server_address + self.database = create_database(uri) # Begin bundle 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 UkSi1KGDADp3KmxM+I9PBtn/4u5IpwoSEX7HzgA=
-- storm mailing list [email protected] Modify settings or unsubscribe at: https://lists.ubuntu.com/mailman/listinfo/storm
