Merge lp:~allenap/maas/database-locks-rerevisited into lp:maas/trunk

Proposed by Gavin Panella on 2015-07-30
Status: Merged
Approved by: Gavin Panella on 2015-08-17
Approved revision: 4152
Merged at revision: 4196
Proposed branch: lp:~allenap/maas/database-locks-rerevisited
Merge into: lp:maas/trunk
Diff against target: 570 lines (+294/-39)
9 files modified
src/maasserver/locks.py (+6/-3)
src/maasserver/models/signals/tests/test_power.py (+5/-2)
src/maasserver/rpc/regionservice.py (+6/-2)
src/maasserver/start_up.py (+5/-2)
src/maasserver/utils/dblocks.py (+16/-5)
src/maasserver/utils/orm.py (+53/-6)
src/maasserver/utils/tests/test_dblocks.py (+71/-3)
src/maasserver/utils/tests/test_orm.py (+86/-16)
src/maastesting/doubles.py (+46/-0)
To merge this branch: bzr merge lp:~allenap/maas/database-locks-rerevisited
Reviewer Review Type Date Requested Status
Blake Rouse 2015-07-30 Approve on 2015-07-30
Review via email: mp+266446@code.launchpad.net

Commit message

Use non-transactional advisory locks when starting the region and setting up the event-loop.

Previously locks were obtained after the transaction had begun. This meant that, once the lock was acquired, the transaction may be working with stale data, resulting in serialization failures at best.

To post a comment you must log in.
Blake Rouse (blake-rouse) wrote :

This looks good. This seems like it relates to a bug. Do you know of a specific bug?

Maybe this bug: https://bugs.launchpad.net/maas/+bug/1458895

review: Approve
Gavin Panella (allenap) wrote :

> This looks good. This seems like it relates to a bug. Do you know of a
> specific bug?
>
> Maybe this bug: https://bugs.launchpad.net/maas/+bug/1458895

That bug looks like a likely candidate; I probably read it and later figured out the fix without remembering the original. Thanks for digging that out, and for the review.

Gavin Panella (allenap) wrote :

FTR, I QA'ed this at home first, and it seems to work as intended.

MAAS Lander (maas-lander) wrote :
Download full text (79.6 KiB)

The attempt to merge lp:~allenap/maas/database-locks-rerevisited into lp:maas failed. Below is the output from the failed tests.

Ign http://security.ubuntu.com trusty-security InRelease
Get:1 http://security.ubuntu.com trusty-security Release.gpg [933 B]
Ign http://nova.clouds.archive.ubuntu.com trusty InRelease
Get:2 http://security.ubuntu.com trusty-security Release [63.5 kB]
Ign http://nova.clouds.archive.ubuntu.com trusty-updates InRelease
Hit http://nova.clouds.archive.ubuntu.com trusty Release.gpg
Get:3 http://nova.clouds.archive.ubuntu.com trusty-updates Release.gpg [933 B]
Hit http://nova.clouds.archive.ubuntu.com trusty Release
Get:4 http://nova.clouds.archive.ubuntu.com trusty-updates Release [63.5 kB]
Get:5 http://security.ubuntu.com trusty-security/main Sources [90.7 kB]
Get:6 http://security.ubuntu.com trusty-security/universe Sources [28.5 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/main Sources
Get:7 http://security.ubuntu.com trusty-security/main amd64 Packages [326 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Sources
Hit http://nova.clouds.archive.ubuntu.com trusty/main amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/universe amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en
Get:8 http://nova.clouds.archive.ubuntu.com trusty-updates/main Sources [227 kB]
Get:9 http://security.ubuntu.com trusty-security/universe amd64 Packages [112 kB]
Get:10 http://security.ubuntu.com trusty-security/main Translation-en [178 kB]
Get:11 http://nova.clouds.archive.ubuntu.com trusty-updates/universe Sources [130 kB]
Get:12 http://security.ubuntu.com trusty-security/universe Translation-en [65.6 kB]
Get:13 http://nova.clouds.archive.ubuntu.com trusty-updates/main amd64 Packages [598 kB]
Get:14 http://nova.clouds.archive.ubuntu.com trusty-updates/universe amd64 Packages [301 kB]
Get:15 http://nova.clouds.archive.ubuntu.com trusty-updates/main Translation-en [288 kB]
Get:16 http://nova.clouds.archive.ubuntu.com trusty-updates/universe Translation-en [160 kB]
Ign http://nova.clouds.archive.ubuntu.com trusty/main Translation-en_US
Ign http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en_US
Fetched 2,635 kB in 3s (740 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
     --no-install-recommends install apache2 authbind bind9 bind9utils build-essential bzr-builddeb chromium-browser chromium-chromedriver curl daemontools debhelper dh-apport dh-systemd distro-info dnsutils firefox freeipmi-tools git gjs ipython isc-dhcp-common libjs-angularjs libjs-jquery libjs-jquery-hotkeys libjs-yui3-full libjs-yui3-min libpq-dev make nodejs-legacy npm pep8 phantomjs postgresql pyflakes python-apt python-bson python-bzrlib python-convoy python-coverage python-crochet python-cssselect python-curtin python-dev python-distro-info python-django python-django-piston python-django-south python-djorm-ext-pgarray python-docutils python-extras python-fixtures python-flake8 python-formencode python-hivex python-httplib2 python-jinja2 python-jsonschema python-lockfile python-lxml pytho...

4149. By Gavin Panella on 2015-08-07

Merged trunk into database-locks-rerevisited.

4150. By Gavin Panella on 2015-08-10

Merged trunk into database-locks-rerevisited.

4151. By Gavin Panella on 2015-08-14

Merged trunk into database-locks-rerevisited.

4152. By Gavin Panella on 2015-08-14

Ensure that the connection is closed in the other thread.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/locks.py'
2--- src/maasserver/locks.py 2015-08-06 13:04:24 +0000
3+++ src/maasserver/locks.py 2015-08-14 16:45:29 +0000
4@@ -18,17 +18,20 @@
5 "startup",
6 ]
7
8-from maasserver.utils.dblocks import DatabaseXactLock
9+from maasserver.utils.dblocks import (
10+ DatabaseLock,
11+ DatabaseXactLock,
12+)
13
14 # Lock around starting-up a MAAS region.
15-startup = DatabaseXactLock(1)
16+startup = DatabaseLock(1)
17
18 # Lock around performing critical security-related operations, like
19 # generating or signing certificates.
20 security = DatabaseXactLock(2)
21
22 # Lock used when starting up the event-loop.
23-eventloop = DatabaseXactLock(3)
24+eventloop = DatabaseLock(3)
25
26 # Lock used to only allow one instance of importing boot images to occur
27 # at a time.
28
29=== modified file 'src/maasserver/models/signals/tests/test_power.py'
30--- src/maasserver/models/signals/tests/test_power.py 2015-06-10 11:24:44 +0000
31+++ src/maasserver/models/signals/tests/test_power.py 2015-08-14 16:45:29 +0000
32@@ -32,7 +32,10 @@
33 MAASServerTestCase,
34 MAASTransactionServerTestCase,
35 )
36-from maasserver.utils.orm import post_commit_hooks
37+from maasserver.utils.orm import (
38+ post_commit_hooks,
39+ transactional,
40+)
41 from maastesting.matchers import (
42 MockCalledOnceWith,
43 MockNotCalled,
44@@ -139,7 +142,7 @@
45
46 def delete_node_then_get_client(uuid):
47 from maasserver.rpc import getClientFor
48- d = deferToThread(node.delete) # Auto-commit outside txn.
49+ d = deferToThread(transactional(node.delete))
50 d.addCallback(lambda _: getClientFor(uuid))
51 return d
52
53
54=== modified file 'src/maasserver/rpc/regionservice.py'
55--- src/maasserver/rpc/regionservice.py 2015-06-16 21:10:26 +0000
56+++ src/maasserver/rpc/regionservice.py 2015-08-14 16:45:29 +0000
57@@ -55,7 +55,10 @@
58 make_validation_error_message,
59 synchronised,
60 )
61-from maasserver.utils.orm import transactional
62+from maasserver.utils.orm import (
63+ transactional,
64+ with_connection,
65+)
66 from netaddr import IPAddress
67 from provisioningserver.rpc import (
68 cluster,
69@@ -757,8 +760,9 @@
70
71 @synchronous
72 @synchronised(lock)
73- @transactional
74+ @with_connection # Needed by the following lock.
75 @synchronised(locks.eventloop)
76+ @transactional
77 def prepare(self):
78 """Ensure that the ``eventloops`` table exists.
79
80
81=== modified file 'src/maasserver/start_up.py'
82--- src/maasserver/start_up.py 2015-07-30 23:44:59 +0000
83+++ src/maasserver/start_up.py 2015-08-14 16:45:29 +0000
84@@ -45,6 +45,7 @@
85 get_psycopg2_exception,
86 post_commit_do,
87 transactional,
88+ with_connection,
89 )
90 from provisioningserver.logger import get_maas_logger
91 from provisioningserver.upgrade_cluster import create_gnupg_home
92@@ -114,8 +115,9 @@
93 break
94
95
96+@with_connection # Needed by the following lock.
97+@synchronised(locks.startup)
98 @transactional
99-@synchronised(locks.startup)
100 def start_import_on_upgrade():
101 """Starts importing `BootResource`s on upgrade from MAAS where the boot
102 images where only stored on the clusters."""
103@@ -168,8 +170,9 @@
104 import_resources()
105
106
107+@with_connection # Needed by the following lock.
108+@synchronised(locks.startup)
109 @transactional
110-@synchronised(locks.startup)
111 def inner_start_up():
112 """Startup jobs that must run serialized w.r.t. other starting servers."""
113 # Register our MAC data type with psycopg.
114
115=== modified file 'src/maasserver/utils/dblocks.py'
116--- src/maasserver/utils/dblocks.py 2014-10-15 10:56:09 +0000
117+++ src/maasserver/utils/dblocks.py 2015-08-14 16:45:29 +0000
118@@ -15,7 +15,9 @@
119 __all__ = [
120 "DatabaseLock",
121 "DatabaseXactLock",
122+
123 "DatabaseLockAttemptOutsideTransaction",
124+ "DatabaseLockAttemptWithoutConnection",
125 "DatabaseLockNotHeld",
126 ]
127
128@@ -29,12 +31,21 @@
129 classid = 20120116
130
131
132+class DatabaseLockAttemptWithoutConnection(Exception):
133+ """A locking attempt was made without a preexisting connection.
134+
135+ :class:`DatabaseLock` should only be used with a preexisting connection.
136+ While this restriction is not absolutely necessary, it's here to ensure
137+ that users of :class:`DatabaseLock` take care with the lifecycle of their
138+ database connection: a connection that is inadvertently closed (by Django,
139+ by MAAS, by anything) will release all locks too.
140+ """
141+
142+
143 class DatabaseLockAttemptOutsideTransaction(Exception):
144 """A locking attempt was made outside of a transaction.
145
146- :class:`DatabaseLock` should only be used within a transaction.
147- Django agressively closes connections outside of atomic blocks to
148- the extent that session-level locks are rendered unreliable at best.
149+ :class:`DatabaseXactLock` should only be used within a transaction.
150 """
151
152
153@@ -125,8 +136,8 @@
154 __slots__ = ()
155
156 def __enter__(self):
157- if not in_transaction():
158- raise DatabaseLockAttemptOutsideTransaction(self)
159+ if connection.connection is None:
160+ raise DatabaseLockAttemptWithoutConnection(self)
161 with closing(connection.cursor()) as cursor:
162 cursor.execute("SELECT pg_advisory_lock(%s, %s)", self)
163
164
165=== modified file 'src/maasserver/utils/orm.py'
166--- src/maasserver/utils/orm.py 2015-08-11 16:49:56 +0000
167+++ src/maasserver/utils/orm.py 2015-08-14 16:45:29 +0000
168@@ -30,6 +30,7 @@
169 'savepoint',
170 'transactional',
171 'validate_in_transaction',
172+ 'with_connection',
173 ]
174
175 from contextlib import contextmanager
176@@ -44,7 +45,6 @@
177
178 from django.core.exceptions import MultipleObjectsReturned
179 from django.db import (
180- close_old_connections,
181 connection,
182 transaction,
183 )
184@@ -398,6 +398,44 @@
185 raise AssertionError("Not callable: %r" % (func,))
186
187
188+@contextmanager
189+def connected():
190+ """Context manager that ensures we're connected to the database.
191+
192+ If there is not yet a connection to the database, this will connect on
193+ entry and disconnect on exit. Preexisting connections will be left alone.
194+ """
195+ if connection.connection is None:
196+ connection.ensure_connection()
197+ try:
198+ yield
199+ finally:
200+ connection.close()
201+ else:
202+ yield
203+
204+
205+def with_connection(func):
206+ """Ensure that we're connected to the database before calling `func`.
207+
208+ If there is not yet a connection to the database, this will connect before
209+ calling the decorated function, and then it will disconnect when done.
210+ Preexisting connections will be left alone.
211+
212+ This can be important when using non-transactional advisory locks.
213+ """
214+ @wraps(func)
215+ def call_with_connection(*args, **kwargs):
216+ with connected():
217+ return func(*args, **kwargs)
218+
219+ # For convenience, when introspecting for example, expose the original
220+ # function on the function we're returning.
221+ call_with_connection.func = func
222+
223+ return call_with_connection
224+
225+
226 def transactional(func):
227 """Decorator that wraps calls to `func` in a Django-managed transaction.
228
229@@ -420,11 +458,20 @@
230 return func_within_txn(*args, **kwargs)
231 else:
232 # Use the retry-capable function, firing post-transaction hooks.
233- try:
234- with post_commit_hooks:
235- return func_outside_txn(*args, **kwargs)
236- finally:
237- close_old_connections()
238+ #
239+ # If there is not yet a connection to the database, connect before
240+ # calling the decorated function, then disconnect when done. This
241+ # can be important when using non-transactional advisory locks
242+ # that may be held before, during, and/or after this transactional
243+ # block.
244+ #
245+ # Previously, close_old_connections() was used here, which would
246+ # close connections without realising that they were still in use
247+ # for non-transactional advisory locking. This had the effect of
248+ # releasing all locks prematurely: not good.
249+ #
250+ with connected(), post_commit_hooks:
251+ return func_outside_txn(*args, **kwargs)
252
253 # For convenience, when introspecting for example, expose the original
254 # function on the function we're returning.
255
256=== modified file 'src/maasserver/utils/tests/test_dblocks.py'
257--- src/maasserver/utils/tests/test_dblocks.py 2015-05-07 18:14:38 +0000
258+++ src/maasserver/utils/tests/test_dblocks.py 2015-08-14 16:45:29 +0000
259@@ -15,6 +15,7 @@
260 __all__ = []
261
262 from contextlib import closing
263+import sys
264
265 from django.db import (
266 connection,
267@@ -32,8 +33,18 @@
268 return {result[0] for result in cursor.fetchall()}
269
270
271+@transaction.atomic
272+def divide_by_zero():
273+ 0 / 0 # In a transaction.
274+
275+
276 class TestDatabaseLock(MAASTestCase):
277
278+ def tearDown(self):
279+ super(TestDatabaseLock, self).tearDown()
280+ with closing(connection.cursor()) as cursor:
281+ cursor.execute("SELECT pg_advisory_unlock_all()")
282+
283 def test_create_lock(self):
284 objid = self.getUniqueInteger()
285 lock = dblocks.DatabaseLock(objid)
286@@ -69,12 +80,69 @@
287 self.assertTrue(lock.is_locked())
288 self.assertFalse(lock.is_locked())
289
290- def test_obtaining_lock_fails_when_outside_of_transaction(self):
291+ def test_lock_remains_held_when_committing_transaction(self):
292+ objid = self.getUniqueInteger()
293+ lock = dblocks.DatabaseLock(objid)
294+ txn = transaction.atomic()
295+
296+ self.assertFalse(lock.is_locked())
297+ txn.__enter__()
298+ self.assertFalse(lock.is_locked())
299+ lock.__enter__()
300+ self.assertTrue(lock.is_locked())
301+ txn.__exit__(None, None, None)
302+ self.assertTrue(lock.is_locked())
303+ lock.__exit__(None, None, None)
304+ self.assertFalse(lock.is_locked())
305+
306+ def test_lock_remains_held_when_aborting_transaction(self):
307+ objid = self.getUniqueInteger()
308+ lock = dblocks.DatabaseLock(objid)
309+ txn = transaction.atomic()
310+
311+ self.assertFalse(lock.is_locked())
312+ txn.__enter__()
313+ self.assertFalse(lock.is_locked())
314+ lock.__enter__()
315+ self.assertTrue(lock.is_locked())
316+
317+ self.assertRaises(ZeroDivisionError, divide_by_zero)
318+ exc_info = sys.exc_info()
319+
320+ txn.__exit__(*exc_info)
321+ self.assertTrue(lock.is_locked())
322+ lock.__exit__(None, None, None)
323+ self.assertFalse(lock.is_locked())
324+
325+ def test_lock_is_held_around_transaction(self):
326+ objid = self.getUniqueInteger()
327+ lock = dblocks.DatabaseLock(objid)
328+
329+ self.assertFalse(lock.is_locked())
330+ with lock:
331+ self.assertTrue(lock.is_locked())
332+ with transaction.atomic():
333+ self.assertTrue(lock.is_locked())
334+ self.assertTrue(lock.is_locked())
335+ self.assertFalse(lock.is_locked())
336+
337+ def test_lock_is_held_around_breaking_transaction(self):
338+ objid = self.getUniqueInteger()
339+ lock = dblocks.DatabaseLock(objid)
340+
341+ self.assertFalse(lock.is_locked())
342+ with lock:
343+ self.assertTrue(lock.is_locked())
344+ self.assertRaises(ZeroDivisionError, divide_by_zero)
345+ self.assertTrue(lock.is_locked())
346+ self.assertFalse(lock.is_locked())
347+
348+ def test_lock_requires_preexisting_connection(self):
349+ connection.close()
350 objid = self.getUniqueInteger()
351 lock = dblocks.DatabaseLock(objid)
352 self.assertRaises(
353- dblocks.DatabaseLockAttemptOutsideTransaction,
354- lock.__enter__)
355+ dblocks.DatabaseLockAttemptWithoutConnection, lock.__enter__)
356
357 def test_releasing_lock_fails_when_lock_not_held(self):
358 objid = self.getUniqueInteger()
359
360=== modified file 'src/maasserver/utils/tests/test_orm.py'
361--- src/maasserver/utils/tests/test_orm.py 2015-08-11 17:03:04 +0000
362+++ src/maasserver/utils/tests/test_orm.py 2015-08-14 16:45:29 +0000
363@@ -52,6 +52,7 @@
364 validate_in_transaction,
365 )
366 from maastesting.djangotestcase import DjangoTransactionTestCase
367+from maastesting.doubles import StubContext
368 from maastesting.factory import factory
369 from maastesting.matchers import (
370 HasLength,
371@@ -561,6 +562,55 @@
372 self.assertRaises(AssertionError, post_commit_do, sentinel.hook)
373
374
375+class TestConnected(DjangoTransactionTestCase):
376+ """Tests for the `orm.connected` context manager."""
377+
378+ def test__ensures_connection(self):
379+ with orm.connected():
380+ self.assertThat(connection.connection, Not(Is(None)))
381+
382+ def test__opens_and_closes_connection_when_no_preexisting_connection(self):
383+ connection.close()
384+
385+ self.assertThat(connection.connection, Is(None))
386+ with orm.connected():
387+ self.assertThat(connection.connection, Not(Is(None)))
388+ self.assertThat(connection.connection, Is(None))
389+
390+ def test__leaves_preexisting_connections_alone(self):
391+ connection.ensure_connection()
392+ preexisting_connection = connection.connection
393+
394+ self.assertThat(connection.connection, Not(Is(None)))
395+ with orm.connected():
396+ self.assertThat(connection.connection, Is(preexisting_connection))
397+ self.assertThat(connection.connection, Is(preexisting_connection))
398+
399+
400+class TestWithConnection(DjangoTransactionTestCase):
401+ """Tests for the `orm.with_connection` decorator."""
402+
403+ def test__exposes_original_function(self):
404+ function = Mock(__name__=self.getUniqueString())
405+ self.assertThat(orm.with_connection(function).func, Is(function))
406+
407+ def test__ensures_function_is_called_within_connected_context(self):
408+ context = self.patch(orm, "connected").return_value = StubContext()
409+
410+ @orm.with_connection
411+ def function(arg, kwarg):
412+ self.assertThat(arg, Is(sentinel.arg))
413+ self.assertThat(kwarg, Is(sentinel.kwarg))
414+ self.assertTrue(context.active)
415+ return sentinel.result
416+
417+ self.assertFalse(context.active)
418+ self.assertThat(
419+ function(sentinel.arg, kwarg=sentinel.kwarg),
420+ Is(sentinel.result))
421+ self.assertFalse(context.active)
422+
423+
424 class TestTransactional(DjangoTransactionTestCase):
425
426 def test__exposes_original_function(self):
427@@ -568,18 +618,19 @@
428 self.assertThat(orm.transactional(function).func, Is(function))
429
430 def test__calls_function_within_transaction_then_closes_connections(self):
431- close_old_connections = self.patch(orm, "close_old_connections")
432+ # Close the database connection to begin with.
433+ connection.close()
434
435- # No transaction has been entered (what Django calls an atomic
436- # block), and old connections have not been closed.
437+ # No transaction has been entered (what Django calls an atomic block),
438+ # and the connection has not yet been established.
439 self.assertFalse(connection.in_atomic_block)
440- self.assertThat(close_old_connections, MockNotCalled())
441+ self.expectThat(connection.connection, Is(None))
442
443 def check_inner(*args, **kwargs):
444- # In here, the transaction (`atomic`) has been started but
445- # is not over, and old connections have not yet been closed.
446+ # In here, the transaction (`atomic`) has been started but is not
447+ # over, and the connection to the database is open.
448 self.assertTrue(connection.in_atomic_block)
449- self.assertThat(close_old_connections, MockNotCalled())
450+ self.expectThat(connection.connection, Not(Is(None)))
451
452 function = Mock()
453 function.__name__ = self.getUniqueString()
454@@ -595,27 +646,49 @@
455 sentinel.arg, kwarg=sentinel.kwarg))
456
457 # After the decorated function has returned the transaction has
458- # been exited, and old connections have been closed.
459- self.assertFalse(connection.in_atomic_block)
460- self.assertThat(close_old_connections, MockCalledOnceWith())
461+ # been exited, and the connection has been closed.
462+ self.assertFalse(connection.in_atomic_block)
463+ self.expectThat(connection.connection, Is(None))
464+
465+ def test__leaves_preexisting_connections_open(self):
466+ # Ensure there's a database connection to begin with.
467+ connection.ensure_connection()
468+
469+ # No transaction has been entered (what Django calls an atomic block),
470+ # but the connection has been established.
471+ self.assertFalse(connection.in_atomic_block)
472+ self.expectThat(connection.connection, Not(Is(None)))
473+
474+ # Call a function via the `transactional` decorator.
475+ decorated_function = orm.transactional(lambda: None)
476+ decorated_function()
477+
478+ # After the decorated function has returned the transaction has
479+ # been exited, but the preexisting connection remains open.
480+ self.assertFalse(connection.in_atomic_block)
481+ self.expectThat(connection.connection, Not(Is(None)))
482
483 def test__closes_connections_only_when_leaving_atomic_block(self):
484- close_old_connections = self.patch(orm, "close_old_connections")
485+ # Close the database connection to begin with.
486+ connection.close()
487+ self.expectThat(connection.connection, Is(None))
488
489 @orm.transactional
490 def inner():
491 # We're inside a `transactional` context here.
492+ self.expectThat(connection.connection, Not(Is(None)))
493 return "inner"
494
495 @orm.transactional
496 def outer():
497 # We're inside a `transactional` context here too.
498+ self.expectThat(connection.connection, Not(Is(None)))
499 # Call `inner`, thus nesting `transactional` contexts.
500 return "outer > " + inner()
501
502 self.assertEqual("outer > inner", outer())
503- # Old connections have been closed only once.
504- self.assertThat(close_old_connections, MockCalledOnceWith())
505+ # The connection has been closed.
506+ self.expectThat(connection.connection, Is(None))
507
508 def test__fires_post_commit_hooks_when_done(self):
509 fire = self.patch(orm.post_commit_hooks, "fire")
510@@ -652,9 +725,6 @@
511 class TestTransactionalRetries(SerializationFailureTestCase):
512
513 def test__retries_upon_serialization_failures(self):
514- # No-op close_old_connections().
515- self.patch(orm, "close_old_connections")
516-
517 function = Mock()
518 function.__name__ = self.getUniqueString()
519 function.side_effect = self.cause_serialization_failure
520
521=== added file 'src/maastesting/doubles.py'
522--- src/maastesting/doubles.py 1970-01-01 00:00:00 +0000
523+++ src/maastesting/doubles.py 2015-08-14 16:45:29 +0000
524@@ -0,0 +1,46 @@
525+# Copyright 2012-2015 Canonical Ltd. This software is licensed under the
526+# GNU Affero General Public License version 3 (see the file LICENSE).
527+
528+"""Miscellaneous test doubles.
529+
530+See http://www.martinfowler.com/bliki/TestDouble.html for the nomenclature
531+used.
532+"""
533+
534+from __future__ import (
535+ absolute_import,
536+ print_function,
537+ unicode_literals,
538+ )
539+
540+str = None
541+
542+__metaclass__ = type
543+__all__ = [
544+ "StubContext",
545+]
546+
547+
548+class StubContext:
549+ """A stub context manager.
550+
551+ :ivar entered: A boolean indicating if the context has been entered.
552+ :ivar exited: A boolean indicating if the context has been exited.
553+ :ivar active: A boolean indicating if the context is currently active
554+ (i.e. it has been entered but not exited).
555+ :ivar exc_info: The ``exc_info`` tuple passed into ``__exit__``.
556+ """
557+
558+ entered = False
559+ exited = False
560+
561+ @property
562+ def active(self):
563+ return self.entered and not self.exited
564+
565+ def __enter__(self):
566+ self.entered = True
567+
568+ def __exit__(self, *exc_info):
569+ self.exc_info = exc_info
570+ self.exited = True