Merge lp:~allenap/python-pgbouncer/reliable-shutdown into lp:python-pgbouncer

Proposed by Gavin Panella
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
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
  OptimizingTestSuite. If the tests were run as suggested in the
  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.

To post a comment you must log in.
15. By Gavin Panella

Fix test_unix_sockets to actually connect via a Unix socket.

Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

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.

Revision history for this message
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.

Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :
Download full text (9.2 KiB)

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/fixture.py'
--- pgbouncer/fixture.py 2011-09-12 23:38:28 +0000
+++ pgbouncer/fixture.py 2011-10-25 20:23:23 +0000

@@ -111,46 +111,56 @@
                 authfile.write('"%s" "%s"\n' % user_creds)

     def stop(self):
- if self.process_pid is None:
- return
- os.kill(self.process_pid, signal.SIGTERM)
- # 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.terminate()
         start = time.time()
         stop = start + 5.0
         while time.time() < stop:
- if not os.path.isfile(self.pidpath):
- 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):
         self.addCleanup(self.stop)

         # 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.startswith('/'):
- path = os.environ['PATH'].split(':')
+ path = env['PATH'].split(os.pathsep)

Better, because simpler.

             if '/usr/sbin' not in path:
                 path.append('/usr/sbin')
- env = os.environ.copy()
- env['PATH'] = ':'.join(path)
-
- outputfile = open(self.outputpath, 'wt')
- self.process = subprocess.Popen(
- [self.pgbouncer, '-d', self.inipath], env=env,
- stdout=outputfile.fileno(), stderr=outputfile.fileno())
- self.process.communicate()
- # Wait up to 5 seconds for the pid file to exist
+ env['PATH'] = os.pathsep.join(path)
+
+ with open(self.outputpath, "wb") as outputfile:
+ with open(os.devnull, "rb") as devnull:
+ self....

Read more...

review: Approve
Revision history for this message
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!

Revision history for this message
Gavin Panella (allenap) wrote :
Download full text (4.8 KiB)

[...]
> 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/tests.py'
> --- 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://www.gnu.org/licenses/>.
>
> 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/lib/postgresql/8.4/bin'
>
> +
> +test_loader = testresources.TestLoader()
> +
> +
> def test_suite():
> - result = testresources.OptimisingTestSuite()
> - result.addTest(TestLoader().loadTestsFromName(__name__))
> - return result
> + return test_loader.loadTestsFromName(__name__)
>
>
> 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.__init__() is not overridden from object() and
loadTestsFromName() does not mutate anything, so this is safe.
However, test_loader.discover() does mutate self so I've changed this
to create a new TestLoader as needed, Just In Case.

>
>
> @@ -49,15 +51,22 @@
>
> resources = [('db', DatabaseManager(initialize_sql=setup_user))]
>
> + def setUp(self):
> + super(TestFixture, self).setUp()
> + self.bouncer = PGBouncerFixture()
> + self.bouncer.databases[self.db.database] = 'host=' + self.db.host
> + self.bouncer.users['user1'] = ''
>
> 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 makeBouncerFixture(self, ...

Read more...

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().

Revision history for this message
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

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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)

Subscribers

People subscribed via source and target branches

to status/vote changes: