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