Merge lp:~allenap/python-pgbouncer/reliable-shutdown into lp:python-pgbouncer
- reliable-shutdown
- Merge into trunk
Status: | Merged | ||||
---|---|---|---|---|---|
Merged at revision: | 9 | ||||
Proposed branch: | lp:~allenap/python-pgbouncer/reliable-shutdown | ||||
Merge into: | lp:python-pgbouncer | ||||
Diff against target: |
301 lines (+110/-82) 4 files modified
.bzrignore (+2/-0) README (+14/-2) pgbouncer/fixture.py (+64/-41) pgbouncer/tests.py (+30/-39) |
||||
To merge this branch: | bzr merge lp:~allenap/python-pgbouncer/reliable-shutdown | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Jeroen T. Vermeulen (community) | Approve | ||
Review via email: mp+80382@code.launchpad.net |
Commit message
Ensure that pgbouncer has shutdown when stop() returns.
Description of the change
While working on lp:~allenap/storm/oneiric-admin-shutdown-bug-871596
I've had problems with the pgbouncer not being quite shutdown right
after calling stop(). This branch changes the way that pgbouncer is
started so that we can be confident that we have stopped it later on.
List of changes:
- Does not start pgbouncer with -d (--daemon) so that the Popen object
can be used to directly monitor and control the process. Previously
it was done at one remove, reading in the daemon's pid from a pid
file.
- Raise an exception if pgbouncer does not stop with 5 seconds of
calling stop(). Previously it would wait for pgbouncer to stop but
then do nothing.
- Adds the pgbouncer output (from stdout and stderr) as a fixture
detail. This means that tests that use the fixture can get
interesting debug information when the test fails.
- Fixes the tests so that they are always loaded into an
OptimizingTes
README they were being loaded into a standard TestSuite.
- Refactors the tests so that they're a lot shorter and a bit easier
to see what they're trying to demonstrate.
- Update the README.
- 15. By Gavin Panella
-
Fix test_unix_sockets to actually connect via a Unix socket.
Jeroen T. Vermeulen (jtv) wrote : | # |
Gavin Panella (allenap) wrote : | # |
> I'm not done reviewing this yet, but a few questions first:
Thanks for looking at it :)
> * I guess if the test dies, pgbouncer will now die as well so you don't need
> a pidfile to set things right during a later run?
Well, I don't know if there are any greater guarantees than
before. Even if we/pgbouncer arranges for SIGHUP to be sent on parent
death it will only cause pgbouncer to reload its config.
I suppose I could add an atexit handler to kill child processes.
> * Why treat the files you open as binary? Take a pidfile, for instance. I
> don't suppose it'll ever matter, but isn't that a text file? Adding the "b"
> to the open modes seems to confuse more than help.
Just a habit. The Python docs state that using "b" improves
portability. Not that it's needed, but it does no harm, and the
"features" of text mode are not needed anyway.
Jeroen T. Vermeulen (jtv) wrote : | # |
Thanks for fixing this, by the way. I see some really good stuff in here, both functionally and in terms of code quality.
=== modified file 'pgbouncer/
--- pgbouncer/
+++ pgbouncer/
@@ -111,46 +111,56 @@
def stop(self):
- if self.process_pid is None:
- return
- os.kill(
- # Wait for the shutdown to occur
+ if self.process is None:
+ # pgbouncer has not been started.
+ return
+ if self.process.poll() is not None:
+ # pgbouncer has exited already.
+ return
+ # Terminate and wait.
+ self.process.
start = time.time()
stop = start + 5.0
while time.time() < stop:
- if not os.path.
- self.process_pid = None
- return
- # If its not going away, we might want to raise an error, but for now
- # it seems reliable.
+ if self.process.poll() is None:
+ time.sleep(0.1)
+ else:
+ break
+ else:
+ raise Exception(
+ 'timeout waiting for pgbouncer to exit')
Some say that the pattern of “comment announcing block of code, block of code” is an indicator that you're probably better off extracting separate methods. I think in this case the terminate-and-wait loop is a bit mechanical and potentially worth extracting. It also gives you a bit more freedom to do things like a direct “return” on success, and to unit test the loop's corner cases. The while / if / sleep / else / break / else / raise loop, while small, may be just a bit too easy to break in maintenance.
Oh, and while you're at it, could you capitalize and punctuate that error message? It helps confirm to the hurried reader that the message is an independent sentence, rather than a continuation of an earlier statement (or of a traceback line) that the eye should skip over.
def start(self):
# Add /usr/sbin if necessary to the PATH for magic just-works
# behavior with Ubuntu.
- env = None
+ env = os.environ.copy()
if not self.pgbouncer.
- path = os.environ[
+ path = env['PATH'
Better, because simpler.
if '/usr/sbin' not in path:
- env = os.environ.copy()
- env['PATH'] = ':'.join(path)
-
- outputfile = open(self.
- self.process = subprocess.Popen(
- [self.pgbouncer, '-d', self.inipath], env=env,
- stdout=
- self.process.
- # Wait up to 5 seconds for the pid file to exist
+ env['PATH'] = os.pathsep.
+
+ with open(self.
+ with open(os.devnull, "rb") as devnull:
+ self....
Jeroen T. Vermeulen (jtv) wrote : | # |
> > * I guess if the test dies, pgbouncer will now die as well so you don't
> need
> > a pidfile to set things right during a later run?
>
> Well, I don't know if there are any greater guarantees than
> before. Even if we/pgbouncer arranges for SIGHUP to be sent on parent
> death it will only cause pgbouncer to reload its config.
>
> I suppose I could add an atexit handler to kill child processes.
If pgbouncer is a child of the test process, it should die along with the process. Don't mess with atexit if you can avoid it. It'd be good to give this a manual try though, just in case. You can probably just insert a long sleep and issue a manual “kill -segv <pid>” to see what happens if the test process dies without any python exit handling whatsoever.
> > * Why treat the files you open as binary? Take a pidfile, for instance. I
> > don't suppose it'll ever matter, but isn't that a text file? Adding the "b"
> > to the open modes seems to confuse more than help.
>
> Just a habit. The Python docs state that using "b" improves
> portability. Not that it's needed, but it does no harm, and the
> "features" of text mode are not needed anyway.
AFAIK it only improves portability for files that aren't pure text!
Gavin Panella (allenap) wrote : | # |
[...]
> Some say that the pattern of “comment announcing block of code, block of code”
> is an indicator that you're probably better off extracting separate methods.
> I think in this case the terminate-and-wait loop is a bit mechanical and
> potentially worth extracting. It also gives you a bit more freedom to do
> things like a direct “return” on success, and to unit test the loop's corner
> cases. The while / if / sleep / else / break / else / raise loop, while
> small, may be just a bit too easy to break in maintenance.
I've factored some of it out into a new countdown() function. I've
stuck with break instead of return, just because. I don't have strong
feelings about early returns, but here I marginally prefer the break.
> Oh, and while you're at it, could you capitalize and punctuate that error
> message? It helps confirm to the hurried reader that the message is an
> independent sentence, rather than a continuation of an earlier statement (or
> of a traceback line) that the eye should skip over.
Done.
[...]
> Very similar to what you were doing in stop(). Once again I think this is
> easier to deal with if you extract it. There's probably a reusable thingy
> somewhere for this basic structure, but I don't suppose it's worth digging up.
Yep, changed as for stop().
> === modified file 'pgbouncer/
> --- pgbouncer/tests.py 2011-09-05 15:01:52 +0000
> +++ pgbouncer/tests.py 2011-10-25 20:23:23 +0000
> @@ -15,7 +15,7 @@
> # along with this program. If not, see <http://
>
> import os
> -from unittest import main, TestLoader
> +import unittest
>
> import fixtures
> import psycopg2
> @@ -28,10 +28,12 @@
> # A 'just-works' workaround for Ubuntu not exposing initdb to the main PATH.
> os.environ['PATH'] = os.environ['PATH'] + ':/usr/
>
> +
> +test_loader = testresources.
> +
> +
> def test_suite():
> - result = testresources.
> - result.
> - return result
> + return test_loader.
>
>
> Not that I have any idea what I'm talking about, but is there any risk that
> this global initialization might cause trouble for “make lint” checks (which I
> believe import the test as a module)?
TestLoader.
loadTestsFromName() does not mutate anything, so this is safe.
However, test_loader.
to create a new TestLoader as needed, Just In Case.
>
>
> @@ -49,15 +51,22 @@
>
> resources = [('db', DatabaseManager
>
> + def setUp(self):
> + super(TestFixture, self).setUp()
> + self.bouncer = PGBouncerFixture()
> + self.bouncer.
> + self.bouncer.
>
> You're doing this test a world of good. Still, it's a shame to jump through
> the “super” hoop *and* create implicit state *and* make an explicit call
> whenever you use the fixture.
>
> Why not fold all of it into a single explicit call? What I mean is:
>
> def makeBouncerFixt
- 16. By Gavin Panella
-
Factor out the countdown code.
- 17. By Gavin Panella
-
Simplify the countdown code.
- 18. By Gavin Panella
-
Improve docstrings and exception messages.
- 19. By Gavin Panella
-
Create TestLoader as needed.
- 20. By Gavin Panella
-
Simplify connect().
Gavin Panella (allenap) wrote : | # |
[...]
> If pgbouncer is a child of the test process, it should die along
> with the process. Don't mess with atexit if you can avoid it. It'd
> be good to give this a manual try though, just in case. You can
> probably just insert a long sleep and issue a manual “kill -segv
> <pid>” to see what happens if the test process dies without any
> python exit handling whatsoever.
SIGSEGV and SIGTERM leave pgbouncer running, bug SIGINT allows
clean-ups to run (because it's a KeyboardInterrupt) so that pgbouncer
gets terminated. I think this is acceptable.
Rob has previously warned (w.r.t. rabbitfixture) not to be too clever
with cleaning stuff up from earlier. He advised something along the
lines of: it's better to let these things go awry and thus draw our
attention to leaks rather than patch things over.
> > > * Why treat the files you open as binary? Take a pidfile, for
> > > instance. I don't suppose it'll ever matter, but isn't that a
> > > text file? Adding the "b" to the open modes seems to confuse
> > > more than help.
> >
> > Just a habit. The Python docs state that using "b" improves
> > portability. Not that it's needed, but it does no harm, and the
> > "features" of text mode are not needed anyway.
>
> AFAIK it only improves portability for files that aren't pure text!
Though there is no different on Linux (afaik), treating every file as
binary reduces surprises on other platforms. Having a distinction
between text and binary reminds me of FTP ASCII mode and what a
horror-show that is.
When writing to a file I rarely use print, for example; I prefer to
use .write(), which means I must think about and insert line-endings
myself. I guess this _might_ tempt me to use text mode for writing a
file, but more often than not I'd simply use Unix line-endings. Only
notepad.exe is likely to have any problem opening files like that.
If I want to read a file line by line that could have any line-ending,
I will either slurp it in and call splitlines(), or use universal
newlines.
In the fixture there are three places where I use binary mode: opening
the output file for pgbouncer, opening the input file (/dev/null) for
pgbouncer, and for reading the PID file.
The first two mimic shell redirections, which must use binary mode (or
else piping tar to gzip would break on Windows).
The PID file is slurped in and immediately stripped (and is expected
to be no more than one line anyway) so line-endings are irrelevant.
Preview Diff
1 | === modified file '.bzrignore' |
2 | --- .bzrignore 2011-07-18 03:31:27 +0000 |
3 | +++ .bzrignore 2011-10-26 14:08:31 +0000 |
4 | @@ -6,3 +6,5 @@ |
5 | ./parts |
6 | ./eggs |
7 | ./download-cache |
8 | +TAGS |
9 | +tags |
10 | |
11 | === modified file 'README' |
12 | --- README 2011-09-05 14:35:29 +0000 |
13 | +++ README 2011-10-26 14:08:31 +0000 |
14 | @@ -30,12 +30,18 @@ |
15 | * python-fixtures (https://launchpad.net/python-fixtures or |
16 | http://pypi.python.org/pypi/fixtures) |
17 | |
18 | +* testtools (http://pypi.python.org/pypi/testtools) |
19 | + |
20 | Testing Dependencies |
21 | ==================== |
22 | |
23 | +In addition to the above, the tests also depend on: |
24 | + |
25 | +* psycopg2 (http://pypi.python.org/pypi/psycopg2) |
26 | + |
27 | * subunit (http://pypi.python.org/pypi/python-subunit) (optional) |
28 | |
29 | -* testtools (http://pypi.python.org/pypi/testtools) |
30 | +* testresources (http://pypi.python.org/pypi/testresources) |
31 | |
32 | * van.pg (http://pypi.python.org/pypi/van.pg) |
33 | |
34 | @@ -75,4 +81,10 @@ |
35 | immediately available, you can use ./bootstrap.py to create bin/buildout, then |
36 | bin/py to get a python interpreter with the dependencies available. |
37 | |
38 | -To run the tests, run 'bin/py pgbouncer/tests.py'. |
39 | +To run the tests, run either: |
40 | + |
41 | + $ bin/py pgbouncer/tests.py |
42 | + |
43 | +or: |
44 | + |
45 | + $ bin/py -m testtools.run pgbouncer.tests.test_suite |
46 | |
47 | === modified file 'pgbouncer/fixture.py' |
48 | --- pgbouncer/fixture.py 2011-09-12 23:38:28 +0000 |
49 | +++ pgbouncer/fixture.py 2011-10-26 14:08:31 +0000 |
50 | @@ -18,13 +18,31 @@ |
51 | 'PGBouncerFixture', |
52 | ] |
53 | |
54 | +import itertools |
55 | import os.path |
56 | import socket |
57 | -import signal |
58 | import subprocess |
59 | import time |
60 | |
61 | from fixtures import Fixture, TempDir |
62 | +from testtools.content import content_from_file |
63 | + |
64 | + |
65 | +def countdown(duration=5.0, sleep=0.1): |
66 | + """Provide a countdown iterator that sleeps between iterations. |
67 | + |
68 | + Yields the current iteration count, starting from 1. |
69 | + """ |
70 | + start = time.time() |
71 | + stop = start + duration |
72 | + for iteration in itertools.count(1): |
73 | + now = time.time() |
74 | + if now < stop: |
75 | + yield iteration |
76 | + time.sleep(sleep) |
77 | + else: |
78 | + break |
79 | + |
80 | |
81 | def _allocate_ports(n=1): |
82 | """Allocate `n` unused ports. |
83 | @@ -46,12 +64,16 @@ |
84 | class PGBouncerFixture(Fixture): |
85 | """Programmatically configure and run pgbouncer. |
86 | |
87 | - >>> Minimal usage: |
88 | - >>> bouncer = PGBouncerFixture() |
89 | - >>> bouncer.databases['mydb'] = 'host=hostname dbname=foo' |
90 | - >>> bouncer.users['user1'] = 'credentials' |
91 | - >>> with bouncer: |
92 | - ... # Can now connect to bouncer.host port=bouncer.port user=user1 |
93 | + Minimal usage: |
94 | + |
95 | + >>> bouncer = PGBouncerFixture() |
96 | + >>> bouncer.databases['mydb'] = 'host=hostname dbname=foo' |
97 | + >>> bouncer.users['user1'] = 'credentials' |
98 | + >>> with bouncer: |
99 | + ... connection = psycopg2.connect( |
100 | + ... database="mydb", host=bouncer.host, port=bouncer.port, |
101 | + ... user="user1", password="credentials") |
102 | + |
103 | """ |
104 | |
105 | def __init__(self): |
106 | @@ -76,7 +98,6 @@ |
107 | self.port = _allocate_ports()[0] |
108 | self.configdir = self.useFixture(TempDir()) |
109 | self.auth_type = 'trust' |
110 | - self.process_pid = None |
111 | self.setUpConf() |
112 | self.start() |
113 | |
114 | @@ -111,46 +132,48 @@ |
115 | authfile.write('"%s" "%s"\n' % user_creds) |
116 | |
117 | def stop(self): |
118 | - if self.process_pid is None: |
119 | - return |
120 | - os.kill(self.process_pid, signal.SIGTERM) |
121 | - # Wait for the shutdown to occur |
122 | - start = time.time() |
123 | - stop = start + 5.0 |
124 | - while time.time() < stop: |
125 | - if not os.path.isfile(self.pidpath): |
126 | - self.process_pid = None |
127 | - return |
128 | - # If its not going away, we might want to raise an error, but for now |
129 | - # it seems reliable. |
130 | + if self.process is None: |
131 | + # pgbouncer has not been started. |
132 | + return |
133 | + if self.process.poll() is not None: |
134 | + # pgbouncer has exited already. |
135 | + return |
136 | + self.process.terminate() |
137 | + for iteration in countdown(): |
138 | + if self.process.poll() is not None: |
139 | + break |
140 | + else: |
141 | + raise Exception( |
142 | + 'Time-out waiting for pgbouncer to exit.') |
143 | |
144 | def start(self): |
145 | self.addCleanup(self.stop) |
146 | |
147 | # Add /usr/sbin if necessary to the PATH for magic just-works |
148 | # behavior with Ubuntu. |
149 | - env = None |
150 | + env = os.environ.copy() |
151 | if not self.pgbouncer.startswith('/'): |
152 | - path = os.environ['PATH'].split(':') |
153 | + path = env['PATH'].split(os.pathsep) |
154 | if '/usr/sbin' not in path: |
155 | path.append('/usr/sbin') |
156 | - env = os.environ.copy() |
157 | - env['PATH'] = ':'.join(path) |
158 | - |
159 | - outputfile = open(self.outputpath, 'wt') |
160 | - self.process = subprocess.Popen( |
161 | - [self.pgbouncer, '-d', self.inipath], env=env, |
162 | - stdout=outputfile.fileno(), stderr=outputfile.fileno()) |
163 | - self.process.communicate() |
164 | - # Wait up to 5 seconds for the pid file to exist |
165 | - start = time.time() |
166 | - stop = start + 5.0 |
167 | - while time.time() < stop: |
168 | + env['PATH'] = os.pathsep.join(path) |
169 | + |
170 | + with open(self.outputpath, "wb") as outputfile: |
171 | + with open(os.devnull, "rb") as devnull: |
172 | + self.process = subprocess.Popen( |
173 | + [self.pgbouncer, self.inipath], env=env, stdin=devnull, |
174 | + stdout=outputfile, stderr=outputfile) |
175 | + |
176 | + self.addDetail( |
177 | + os.path.basename(self.outputpath), |
178 | + content_from_file(self.outputpath)) |
179 | + |
180 | + # Wait for the PID file to appear. |
181 | + for iteration in countdown(): |
182 | if os.path.isfile(self.pidpath): |
183 | - try: |
184 | - self.process_pid = int(file(self.pidpath, 'rt').read()) |
185 | - except ValueError: |
186 | - # Empty pid files -> ValueError. |
187 | - continue |
188 | - return |
189 | - raise Exception('timeout waiting for pgbouncer to create pid file') |
190 | + with open(self.pidpath, "rb") as pidfile: |
191 | + if pidfile.read().strip().isdigit(): |
192 | + break |
193 | + else: |
194 | + raise Exception( |
195 | + 'Time-out waiting for pgbouncer to create PID file.') |
196 | |
197 | === modified file 'pgbouncer/tests.py' |
198 | --- pgbouncer/tests.py 2011-09-05 15:01:52 +0000 |
199 | +++ pgbouncer/tests.py 2011-10-26 14:08:31 +0000 |
200 | @@ -15,7 +15,7 @@ |
201 | # along with this program. If not, see <http://www.gnu.org/licenses/>. |
202 | |
203 | import os |
204 | -from unittest import main, TestLoader |
205 | +import unittest |
206 | |
207 | import fixtures |
208 | import psycopg2 |
209 | @@ -28,10 +28,10 @@ |
210 | # A 'just-works' workaround for Ubuntu not exposing initdb to the main PATH. |
211 | os.environ['PATH'] = os.environ['PATH'] + ':/usr/lib/postgresql/8.4/bin' |
212 | |
213 | + |
214 | def test_suite(): |
215 | - result = testresources.OptimisingTestSuite() |
216 | - result.addTest(TestLoader().loadTestsFromName(__name__)) |
217 | - return result |
218 | + loader = testresources.TestLoader() |
219 | + return loader.loadTestsFromName(__name__) |
220 | |
221 | |
222 | class ResourcedTestCase(testtools.TestCase, testresources.ResourcedTestCase): |
223 | @@ -49,15 +49,21 @@ |
224 | |
225 | resources = [('db', DatabaseManager(initialize_sql=setup_user))] |
226 | |
227 | + def setUp(self): |
228 | + super(TestFixture, self).setUp() |
229 | + self.bouncer = PGBouncerFixture() |
230 | + self.bouncer.databases[self.db.database] = 'host=' + self.db.host |
231 | + self.bouncer.users['user1'] = '' |
232 | + |
233 | + def connect(self, host=None): |
234 | + return psycopg2.connect( |
235 | + host=(self.bouncer.host if host is None else host), |
236 | + port=self.bouncer.port, database=self.db.database, |
237 | + user='user1') |
238 | + |
239 | def test_dynamic_port_allocation(self): |
240 | - bouncer = PGBouncerFixture() |
241 | - db = self.db |
242 | - bouncer.databases[db.database] = 'host=%s' % (db.host,) |
243 | - bouncer.users['user1'] = '' |
244 | - with bouncer: |
245 | - conn = psycopg2.connect(host=bouncer.host, port=bouncer.port, |
246 | - user='user1', database=db.database) |
247 | - conn.close() |
248 | + self.useFixture(self.bouncer) |
249 | + self.connect().close() |
250 | |
251 | def test_stop_start_facility(self): |
252 | # Once setup the fixture can be stopped, and started again, retaining |
253 | @@ -65,36 +71,21 @@ |
254 | # potentially be used by a different process, so this isn't perfect, |
255 | # but its pretty reliable as a test helper, and manual port allocation |
256 | # outside the dynamic range should be fine. |
257 | - bouncer = PGBouncerFixture() |
258 | - db = self.db |
259 | - bouncer.databases[db.database] = 'host=%s' % (db.host,) |
260 | - bouncer.users['user1'] = '' |
261 | - def check_connect(): |
262 | - conn = psycopg2.connect(host=bouncer.host, port=bouncer.port, |
263 | - user='user1', database=db.database) |
264 | - conn.close() |
265 | - with bouncer: |
266 | - current_port = bouncer.port |
267 | - bouncer.stop() |
268 | - self.assertRaises(psycopg2.OperationalError, check_connect) |
269 | - bouncer.start() |
270 | - check_connect() |
271 | + self.useFixture(self.bouncer) |
272 | + self.bouncer.stop() |
273 | + self.assertRaises(psycopg2.OperationalError, self.connect) |
274 | + self.bouncer.start() |
275 | + self.connect().close() |
276 | |
277 | def test_unix_sockets(self): |
278 | - db = self.db |
279 | unix_socket_dir = self.useFixture(fixtures.TempDir()).path |
280 | - bouncer = PGBouncerFixture() |
281 | - bouncer.databases[db.database] = 'host=%s' % (db.host,) |
282 | - bouncer.users['user1'] = '' |
283 | - bouncer.unix_socket_dir = unix_socket_dir |
284 | - with bouncer: |
285 | - # Connect to pgbouncer via a Unix domain socket. We don't |
286 | - # care how pgbouncer connects to PostgreSQL. |
287 | - conn = psycopg2.connect( |
288 | - host=unix_socket_dir, user='user1', |
289 | - database=db.database, port=bouncer.port) |
290 | - conn.close() |
291 | + self.bouncer.unix_socket_dir = unix_socket_dir |
292 | + self.useFixture(self.bouncer) |
293 | + # Connect to pgbouncer via a Unix domain socket. We don't |
294 | + # care how pgbouncer connects to PostgreSQL. |
295 | + self.connect(host=unix_socket_dir).close() |
296 | |
297 | |
298 | if __name__ == "__main__": |
299 | - main(testLoader=TestLoader()) |
300 | + loader = testresources.TestLoader() |
301 | + unittest.main(testLoader=loader) |
I'm not done reviewing this yet, but a few questions first:
* I guess if the test dies, pgbouncer will now die as well so you don't need a pidfile to set things right during a later run?
* Why treat the files you open as binary? Take a pidfile, for instance. I don't suppose it'll ever matter, but isn't that a text file? Adding the "b" to the open modes seems to confuse more than help.