Merge lp:~gary/launchpad/bug844631 into lp:launchpad

Proposed by Gary Poster on 2011-09-30
Status: Merged
Approved by: Gary Poster on 2011-10-03
Approved revision: 13981
Merged at revision: 14096
Proposed branch: lp:~gary/launchpad/bug844631
Merge into: lp:launchpad
Diff against target: 790 lines (+482/-61) 9 files modified
To merge this branch: bzr merge lp:~gary/launchpad/bug844631
Reviewer Review Type Date Requested Status
Brad Crittenden code 2011-09-30 Approve on 2011-09-30
Robert Collins (community) Approve on 2011-09-30
Review via email: mp+77725@code.launchpad.net

Commit Message

[r=bac,lifeless][bug=844631] Add an error page for when the database is down (such as fastdowntime) or has serious issues.

Description of the Change

= Overview =

This branch adds an error page for when the database is down (such as fastdowntime) or has serious issues.

It catches both OperationalError and DisconnectionError because, on experimentation, it seems that the database raises OperationalError if it can't connect initially (such as after an app restart when the db is down), and (as expected) DisconnectionError if it was connected but lost the connection.

= UI =

The page design is by Huw, except for the status update section, which I had to rewrite because of technical reasons (see below).

= Implementation =

The registration of the page is straightforward. The branch only has two elements of real interest (and which took up my time).

== Testing ==

The first exciting bit was testing. Stuart suggested that I write integration tests using pgbouncer. This was a bit challenging because it found some edge cases in using the pgbouncer. In particular, you'll see an odd little loop in the test where I get rid of two 500 errors. This was about the best I could do. I talked with him about this for some time and had some alternatives, but we settled on this. I'm not sure what to tell you about it, even though it took a longer time than expected.

== Status updates ==

The second element of this branch that took up my time is the inclusion of the status updates on the page.

=== Rejected ideas ===

(Feel free to skip; I feel compelled to share.)

Huw initially had done this with a Twitter-supplied widget that had a number of problems, such as relying on http. He found another widget, but it relied on jsonp, which would introduce a security hole for our site (http://en.wikipedia.org/wiki/JSONP#Security_concerns). Matthew proposed reusing the same style of memcached RSS loading that we use on the front page for the blog listings, but that was risky for a page that is supposed to be shown when the system is having problems already, and also would have required poking another hole in our firewall. Robert proposed the interesting idea of having a static page hosted in Apache over http which used jsonp, and redirect the appserver error page to that page. That probably would have worked, but I was hoping for a lighter weight solution.

=== Implementation: the story ===

(OK, you can skip this too. This took so long that I feel like talking about it, I guess.)

I next explored the Flash xdr method that YUI provides. Identi.ca does not provide a http://identi.ca/crossdomain.xml , which Flash requires. twitter.com and api.twitter.com explicitly have a crossdomain.xml that only allows their own sites to use it. After a bit of digging, I discovered that search.twitter.com does have a friendly crossdomain.xml. I hooked it up and it was working.

Unfortunately, it used Flash.

Next, I discovered CORS (http://en.wikipedia.org/wiki/Cross-Origin_Resource_Sharing). Yay! This is the answer we want.

=== Implementation: the meat ===

(Read this part, please.)

I try CORS first. That should work on modern Ubuntu installs. If that fails, I fall back to Flash. If that fails, I give up. This seems to work. Yay.

=== Testing the JS out yourself ===

You probably could just shut down PG while you run LP, but I installed a temporary view to look at it.

=== modified file 'lib/lp/app/browser/configure.zcml'
--- lib/lp/app/browser/configure.zcml 2011-09-29 02:32:20 +0000
+++ lib/lp/app/browser/configure.zcml 2011-09-29 18:54:11 +0000
@@ -377,6 +377,15 @@
       class="canonical.launchpad.webapp.error.OperationalErrorView"
       />

+ <!-- XXX Remove -->
+ <browser:page
+ for="*"
+ name="disconnected.html"
+ permission="zope.Public"
+ template="../templates/launchpad-databaseunavailable.pt"
+ class="canonical.launchpad.webapp.error.DisconnectionErrorView"
+ />
+
   <!-- UnsafeFormGetSubmissionError -->
   <browser:page
       for="canonical.launchpad.webapp.interfaces.UnsafeFormGetSubmissionError"

== Lint ==

lint is happy AFAICT except for the usual long line complaints in the lazr config file, but "make lint" is confused and gives me lint on the whole tree, so it's possible I missed something.

That's it.

Thanks!

Gary

To post a comment you must log in.
Gary Poster (gary) wrote :

I forgot to mention the following:

- lib/canonical/launchpad/webapp/publication.py has deleted code because Stuart told me to. See comments 14 and 2 of https://bugs.launchpad.net/launchpad/+bug/844631 .

- There are a number of extraneous lint cleanups, because of touching files.

- The change to the profile module is there because I noticed the bad logic while working on the tests. It's otherwise unrelated, and trivial.

Thanks

lp:~gary/launchpad/bug844631 updated on 2011-09-30
13978. By Gary Poster on 2011-09-30

address wording per review

Robert Collins (lifeless) wrote :

+1 with the reinstatement of the publication code - stub didn't mean to remove the reset and rollback calls - they are not things we can push into storm; OTOH the assertion he mentions we would have expected to be fixed. But, we're still seeing it so its clearly not fixed, -> I don't think we can remove it.

We'll want to address that bug promptly so that folk see the right error page, but that can be a different landing.

review: Approve
Brad Crittenden (bac) wrote :

Thanks for the nice work Gary. I know the branch had lots of frustrations but I'm glad you got them all tamed.

I wonder if it would've been better to have split this effort into two branches: one for the page and another to add the status messages. Might've helped you with momentum.

On IRC we discussed improvements to the text of the page and I like the final version you wrote. I like the convivial nature of the page but it does need to read smoothly too.

I notice you put up the spinner statically which has the downside of being displayed permanently if JS is not enabled in the browser. I think it should be displayed conditionally. The header "Recent status updates" should also only be showed if there is a chance it will be populated.

Otherwise it looks good. You just mentioned on IRC you'll reinstate the publisher code.

Also, thanks for the lint cleanup.

review: Approve (code)
lp:~gary/launchpad/bug844631 updated on 2011-10-03
13979. By Gary Poster on 2011-09-30

reinstate publication.py changes per review

13980. By Gary Poster on 2011-09-30

hide JS-populated bits from JS browsers

13981. By Gary Poster on 2011-10-02

fix test failures

13982. By Gary Poster on 2011-10-03

address test failures

13983. By Gary Poster on 2011-10-03

reinstate branch, with fixes.

Preview Diff

1=== modified file 'lib/canonical/config/schema-lazr.conf'
2--- lib/canonical/config/schema-lazr.conf 2011-10-03 00:24:04 +0000
3+++ lib/canonical/config/schema-lazr.conf 2011-10-03 14:45:28 +0000
4@@ -1211,6 +1211,17 @@
5 # The number of items to display:
6 homepage_recent_posts_count: 6
7
8+# The URL to the CORS-friendly JSON feed of Launchpad status updates
9+# that is used on some error pages.
10+launchpadstatus_json_cors_feed: https://identi.ca/api/statuses/user_timeline/84819.json
11+
12+# The URL to the Flash-friendly JSON feed of Launchpad status updates
13+# that is used on some error pages when the CORS feed does not work.
14+# Note that this has a slightly different structure than the identi.ca
15+# feed above: you must get the "results" from this data structure in
16+# order to get the same results as the identi.ca one.
17+launchpadstatus_json_flash_feed: https://search.twitter.com/search.json?q=from:launchpadstatus
18+
19 # The URL of the XML-RPC endpoint that allows querying of feature
20 # flags. This should implement IFeatureFlagApplication.
21 #
22
23=== added file 'lib/canonical/launchpad/images/identica_logo.png'
24Binary files lib/canonical/launchpad/images/identica_logo.png 1970-01-01 00:00:00 +0000 and lib/canonical/launchpad/images/identica_logo.png 2011-10-03 14:45:28 +0000 differ
25=== added file 'lib/canonical/launchpad/images/twitter_logo.png'
26Binary files lib/canonical/launchpad/images/twitter_logo.png 1970-01-01 00:00:00 +0000 and lib/canonical/launchpad/images/twitter_logo.png 2011-10-03 14:45:28 +0000 differ
27=== modified file 'lib/canonical/launchpad/webapp/error.py'
28--- lib/canonical/launchpad/webapp/error.py 2011-10-03 00:24:04 +0000
29+++ lib/canonical/launchpad/webapp/error.py 2011-10-03 14:45:28 +0000
30@@ -287,3 +287,16 @@
31 def isSystemError(self):
32 """We don't need to log these errors in the SiteLog."""
33 return False
34+
35+
36+class DisconnectionErrorView(SystemErrorView):
37+
38+ response_code = httplib.SERVICE_UNAVAILABLE
39+ reason = u'our database being temporarily offline'
40+ cors_feed = config.launchpad.launchpadstatus_json_cors_feed
41+ flash_feed = config.launchpad.launchpadstatus_json_flash_feed
42+
43+
44+class OperationalErrorView(DisconnectionErrorView):
45+
46+ reason = u'our database having temporary operational issues'
47
48=== modified file 'lib/canonical/launchpad/webapp/tests/test_error.py'
49--- lib/canonical/launchpad/webapp/tests/test_error.py 2011-10-03 00:24:04 +0000
50+++ lib/canonical/launchpad/webapp/tests/test_error.py 2011-10-03 14:45:28 +0000
51@@ -3,10 +3,27 @@
52
53 """Test error views."""
54
55-from canonical.launchpad.webapp.error import SystemErrorView
56+import urllib2
57+
58+from storm.exceptions import (
59+ DisconnectionError,
60+ OperationalError,
61+ )
62+import transaction
63+
64+from canonical.launchpad.webapp.error import (
65+ DisconnectionErrorView,
66+ OperationalErrorView,
67+ SystemErrorView,
68+ )
69 from canonical.launchpad.webapp.servers import LaunchpadTestRequest
70 from canonical.testing.layers import LaunchpadFunctionalLayer
71 from lp.testing import TestCase
72+from lp.testing.fixture import (
73+ PGBouncerFixture,
74+ Urllib2Fixture,
75+ )
76+from lp.testing.matchers import Contains
77
78
79 class TestSystemErrorView(TestCase):
80@@ -29,3 +46,76 @@
81 self.assertEquals(
82 'OOPS-1X1',
83 request.response.getHeader('X-Lazr-OopsId', literal=True))
84+
85+
86+class TestDatabaseErrorViews(TestCase):
87+
88+ layer = LaunchpadFunctionalLayer
89+
90+ def getHTTPError(self, url):
91+ try:
92+ urllib2.urlopen(url)
93+ except urllib2.HTTPError, error:
94+ return error
95+ else:
96+ self.fail("We should have gotten an HTTP error")
97+
98+ def test_disconnectionerror_view_integration(self):
99+ # Test setup.
100+ self.useFixture(Urllib2Fixture())
101+ bouncer = PGBouncerFixture()
102+ self.useFixture(bouncer)
103+ # Verify things are working initially.
104+ url = 'http://launchpad.dev/'
105+ urllib2.urlopen(url)
106+ # Now break the database, and we get an exception, along with
107+ # our view.
108+ bouncer.stop()
109+ for i in range(2):
110+ # This should not happen ideally, but Stuart is OK with it
111+ # for now. His explanation is that the first request
112+ # makes the PG recognize that the slave DB is
113+ # disconnected, the second one makes PG recognize that the
114+ # master DB is disconnected, and third and subsequent
115+ # requests, as seen below, correctly generate a
116+ # DisconnectionError. Oddly, these are ProgrammingErrors.
117+ self.getHTTPError(url)
118+ error = self.getHTTPError(url)
119+ self.assertEqual(503, error.code)
120+ self.assertThat(error.read(),
121+ Contains(DisconnectionErrorView.reason))
122+ # We keep seeing the correct exception on subsequent requests.
123+ self.assertEqual(503, self.getHTTPError(url).code)
124+ # When the database is available again, requests succeed.
125+ bouncer.start()
126+ urllib2.urlopen(url)
127+
128+ def test_disconnectionerror_view(self):
129+ request = LaunchpadTestRequest()
130+ DisconnectionErrorView(DisconnectionError(), request)
131+ self.assertEquals(503, request.response.getStatus())
132+
133+ def test_operationalerror_view_integration(self):
134+ # Test setup.
135+ self.useFixture(Urllib2Fixture())
136+ bouncer = PGBouncerFixture()
137+ self.useFixture(bouncer)
138+ # This is necessary to avoid confusing PG after the stopped bouncer.
139+ transaction.abort()
140+ # Database is down initially, causing an OperationalError.
141+ bouncer.stop()
142+ url = 'http://launchpad.dev/'
143+ error = self.getHTTPError(url)
144+ self.assertEqual(503, error.code)
145+ self.assertThat(error.read(),
146+ Contains(OperationalErrorView.reason))
147+ # We keep seeing the correct exception on subsequent requests.
148+ self.assertEqual(503, self.getHTTPError(url).code)
149+ # When the database is available again, requests succeed.
150+ bouncer.start()
151+ urllib2.urlopen(url)
152+
153+ def test_operationalerror_view(self):
154+ request = LaunchpadTestRequest()
155+ OperationalErrorView(OperationalError(), request)
156+ self.assertEquals(503, request.response.getStatus())
157
158=== modified file 'lib/canonical/testing/layers.py'
159--- lib/canonical/testing/layers.py 2011-10-03 00:24:04 +0000
160+++ lib/canonical/testing/layers.py 2011-10-03 14:45:28 +0000
161@@ -50,6 +50,7 @@
162 'ZopelessLayer',
163 'disconnect_stores',
164 'reconnect_stores',
165+ 'wsgi_application',
166 ]
167
168 from cProfile import Profile
169
170=== modified file 'lib/lp/app/browser/configure.zcml'
171--- lib/lp/app/browser/configure.zcml 2011-10-03 00:24:04 +0000
172+++ lib/lp/app/browser/configure.zcml 2011-10-03 14:45:28 +0000
173@@ -359,6 +359,24 @@
174 class="canonical.launchpad.webapp.error.OpenIdDiscoveryFailureView"
175 />
176
177+ <!-- DisconnectionError -->
178+ <browser:page
179+ for="storm.exceptions.DisconnectionError"
180+ name="index.html"
181+ permission="zope.Public"
182+ template="../templates/launchpad-databaseunavailable.pt"
183+ class="canonical.launchpad.webapp.error.DisconnectionErrorView"
184+ />
185+
186+ <!-- OperationalError -->
187+ <browser:page
188+ for="storm.exceptions.OperationalError"
189+ name="index.html"
190+ permission="zope.Public"
191+ template="../templates/launchpad-databaseunavailable.pt"
192+ class="canonical.launchpad.webapp.error.OperationalErrorView"
193+ />
194+
195 <!-- UnsafeFormGetSubmissionError -->
196 <browser:page
197 for="canonical.launchpad.webapp.interfaces.UnsafeFormGetSubmissionError"
198
199=== added file 'lib/lp/app/templates/launchpad-databaseunavailable.pt'
200--- lib/lp/app/templates/launchpad-databaseunavailable.pt 1970-01-01 00:00:00 +0000
201+++ lib/lp/app/templates/launchpad-databaseunavailable.pt 2011-10-03 14:45:28 +0000
202@@ -0,0 +1,293 @@
203+<html
204+ xmlns="http://www.w3.org/1999/xhtml"
205+ xmlns:tal="http://xml.zope.org/namespaces/tal"
206+ xml:lang="en"
207+ lang="en"
208+>
209+ <head>
210+ <title>Error: database unavailable</title>
211+
212+ <style type="text/css">
213+
214+html {
215+ background: #fff;
216+ margin: 0;
217+ padding: 0;
218+}
219+
220+body {
221+ font-family: 'UbuntuBeta Regular', Ubuntu, 'Bitstream Vera Sans', 'DejaVu Sans', Tahoma, sans-serif;
222+ font-size: 0.75em;
223+ line-height: 1.3em;
224+ color: #000;
225+ margin: 0 0 40px 0;
226+}
227+
228+a {
229+ color: #03a;
230+ text-decoration: none;
231+}
232+
233+h1 {
234+ font-size: 3.7em;
235+}
236+
237+h2,
238+h3,
239+h4 {
240+ font-weight: normal;
241+}
242+
243+h2 {
244+ margin: 1.3em 0 1em 0;
245+ font-size: 1.8em;
246+ line-height: 26px;
247+}
248+
249+h3, h4{
250+ margin: 0;
251+}
252+
253+h4 {
254+ font-size: 1.4em;
255+ color: #333;
256+}
257+
258+p.sub {
259+ color: #333;
260+}
261+
262+p.large {
263+ font-size: 1.2em;
264+}
265+
266+a img {
267+ vertical-align: middle;
268+}
269+
270+.page-center-wrap {
271+ text-align: left;
272+}
273+.page-center {
274+ text-align: center;
275+ margin: 0px;
276+ margin: auto;
277+ width: 780px;
278+}
279+.page-center-content {
280+ text-align: left;
281+}
282+
283+#header,
284+#status-feed,
285+#status-subscribe,
286+#footer {
287+ padding-left: 50px;
288+ padding-right: 50px;
289+}
290+
291+#header {
292+ border: 1px solid #d7d3cf;
293+ padding-top: 20px;
294+ padding-bottom: 20px;
295+ margin-top: 15%;
296+}
297+
298+#header .graphic {
299+ font-size: 110px;
300+ color: #646464;
301+ float: right;
302+ padding: 100px 80px 0 0;
303+}
304+
305+#header .content {
306+ margin-right: 300px;
307+}
308+
309+#header-shadow {
310+ height: 2px;
311+ background-color: #ebe9e7;
312+}
313+
314+#status-feed {
315+ padding-top: 10px;
316+ width: 450px;
317+ display: none;
318+}
319+
320+#status-feed h2 {
321+ border-bottom: 1px solid #d7d3cf;
322+ padding-bottom: 0.7em;
323+ margin-bottom: 0.3em;
324+}
325+
326+#status-feed span.datetime {
327+ color: gray;
328+ font-size: smaller;
329+}
330+
331+#status-feed div {
332+ text-align: center;
333+}
334+
335+#status-subscribe img {
336+ margin-right: 3px;
337+}
338+
339+#status-subscribe a:last-child {
340+ margin-left: 20px;
341+}
342+
343+#footer {
344+ border-top: 1px solid #d7d3cf;
345+ margin-top: 40px;
346+ padding-top: 20px;
347+}
348+
349+</style>
350+
351+ <metal:load-lavascript use-macro="context/@@+base-layout-macros/load-javascript"
352+/>
353+ <script tal:define="
354+ revno modules/lp.app.versioninfo/revno | string:unknown;
355+ icingroot string:/+icing/rev${revno};
356+ yui string:${icingroot}/yui;"
357+ tal:content="
358+string:var lp_yui_root = '${yui}';
359+lp_status_flash_feed = '${view/flash_feed}';
360+lp_status_cors_feed = '${view/cors_feed}';
361+"
362+></script>
363+ <script id="load-status" type="text/javascript">
364+LPS.use('node', 'io-xdr', 'json-parse', 'datatype-date', function(Y) {
365+ var tag = function(name) {
366+ return Y.Node.create('<'+name+'/>');
367+ }
368+ var li = function(text, created_at) {
369+ var datetime = Y.DataType.Date.parse(created_at);
370+ return tag('li')
371+ .append(
372+ tag('span')
373+ .addClass('datetime')
374+ .set('text', Y.DataType.Date.format(datetime, {format: "%F %X %Z"})))
375+ .append(tag('br'))
376+ .append(
377+ tag('span')
378+ .addClass('text')
379+ .set('text', text));
380+ }
381+ Y.on('load', function(e){
382+ var destination = Y.one('#status-feed');
383+ // The feed block starts hidden so that browsers without JS do not
384+ // see it. We have JS, so we can show it.
385+ destination.setStyle('display', 'block');
386+ var _handleSuccess = function(o, get_results) {
387+ var rawJSON = Y.JSON.parse(o.responseText);
388+ var oRSS = get_results(rawJSON);
389+ if (oRSS && oRSS.length) {
390+ destination.one('div').setStyle('display', 'none');
391+ var l = destination.one('ul');
392+ for (var i=0; i<Math.min(oRSS.length, 5); i++) {
393+ var entry = oRSS[i];
394+ l.append(li(entry.text, entry.created_at));
395+ }
396+ }
397+ };
398+ var handleTwitterSuccess = function(id, o, a){
399+ _handleSuccess(o, function(json) {return json.results;});
400+ };
401+ var handleIdenticaSuccess = function(id, o, a){
402+ _handleSuccess(o, function(json) {return json;});
403+ }
404+ var loadStatusWithFlash = function(){
405+ var obj = Y.io(
406+ lp_status_flash_feed,
407+ {
408+ method: 'GET',
409+ xdr: {use: 'flash'},
410+ on: {
411+ success: handleTwitterSuccess,
412+ failure: function (id, o, a){
413+ Y.log("ERROR " + id + " " + a, "info", "example");
414+ Y.one('#status-feed div').set(
415+ 'text', '[Sorry, the feed could not load.]');
416+ }
417+ }
418+ });
419+ };
420+ var loadStatusWithCORS = function() {
421+ // CORS: http://www.w3.org/TR/cors/
422+ // Identi.ca supports it; Twitter does not at this time.
423+ var obj = Y.io(
424+ lp_status_cors_feed,
425+ {
426+ // This is important for Chromium. Otherwise, the XHR will fail.
427+ headers: { 'X-Requested-With': 'disable'},
428+ on: {
429+ success: handleIdenticaSuccess,
430+ failure: function(id, o, a){
431+ Y.log("ERROR " + id + " " + a, "info", "example");
432+ Y.io.transport({id: 'flash', src: lp_yui_root + '/io/io.swf'});
433+ Y.on('io:xdrReady', loadStatusWithFlash);
434+ }
435+ }
436+ });
437+ };
438+ loadStatusWithCORS();
439+ }, window);
440+});
441+ </script>
442+</head>
443+<body>
444+ <div class="page-center-wrap">
445+ <div class="page-center">
446+ <div class="page-center-content">
447+ <div id="header">
448+ <div class="graphic">
449+ :(
450+ </div>
451+ <div class="content">
452+ <h1>Uh oh!</h1>
453+ <h4>Something has gone wrong. We're sorry!</h4>
454+ <p>If we are in the middle of an update, Launchpad will be
455+ back in less than five minutes. Otherwise, we are working
456+ to fix the unexpected problems.</p>
457+ <p class="sub">Technically, this is a 503 error and has
458+ been caused by
459+ <span tal:replace="view/reason">our database
460+ being temporarily offline</span>.
461+ </p>
462+ <p><a href="javascript:window.location.reload()">Reload</a>
463+ this page or try again in a few minutes</p>
464+ </div>
465+ </div>
466+ <div id="header-shadow"></div>
467+ <div id="status-feed">
468+ <h2>Recent status updates</h2>
469+ <div><img src="/@@/spinner.gif" /></div>
470+ <ul></ul>
471+ </div>
472+ <div id="status-subscribe">
473+ <h2>Subscribe to updates</h2>
474+ <a href="http://identi.ca/launchpadstatus">
475+ <img src="/@@/identica_logo.png" alt="Identi.ca" />
476+ @launchpadstatus
477+ </a>
478+ on Identi.ca
479+ <a href="http://twitter.com/launchpadstatus">
480+ <img src="/@@/twitter_logo.png" alt="Twitter" />
481+ @launchpadstatus
482+ </a>
483+ on Twitter
484+ </div>
485+ <div id="footer">
486+ <a href="http://launchpad.net/">
487+ <img src="/@@/launchpad-logo-and-name-hierarchy.png"
488+ alt="Launchpad" />
489+ </a>
490+ </div>
491+ </div>
492+ </div>
493+ </div>
494+</body>
495+</html>
496
497=== modified file 'lib/lp/services/profile/profile.py'
498--- lib/lp/services/profile/profile.py 2011-10-03 00:24:04 +0000
499+++ lib/lp/services/profile/profile.py 2011-10-03 14:45:28 +0000
500@@ -209,10 +209,10 @@
501 # still running.
502 assert _profilers.profiler is None
503 actions = get_desired_profile_actions(event.request)
504- _profilers.actions = actions
505- _profilers.profiling = True
506 if config.profiling.profile_all_requests:
507 actions['callgrind'] = ''
508+ if config.profiling.memory_profile_log:
509+ actions['memory_profile_start'] = (memory(), resident())
510 if actions:
511 if 'sql' in actions:
512 condition = actions['sql']
513@@ -222,8 +222,8 @@
514 if 'show' in actions or available_profilers.intersection(actions):
515 _profilers.profiler = Profiler()
516 _profilers.profiler.start()
517- if config.profiling.memory_profile_log:
518- _profilers.memory_profile_start = (memory(), resident())
519+ _profilers.profiling = True
520+ _profilers.actions = actions
521
522 template = PageTemplateFile(
523 os.path.join(os.path.dirname(__file__), 'profile.pt'))
524@@ -352,6 +352,17 @@
525 template_context['multiple_profiles'] = prof_stats.count > 1
526 # Try to free some more memory.
527 del prof_stats
528+ # Dump memory profiling info.
529+ if 'memory_profile_start' in actions:
530+ log = file(config.profiling.memory_profile_log, 'a')
531+ vss_start, rss_start = actions.pop('memory_profile_start')
532+ vss_end, rss_end = memory(), resident()
533+ if oopsid is None:
534+ oopsid = '-'
535+ log.write('%s %s %s %f %d %d %d %d\n' % (
536+ timestamp, pageid, oopsid, da.get_request_duration(),
537+ vss_start, rss_start, vss_end, rss_end))
538+ log.close()
539 trace = None
540 if 'sql' in actions:
541 trace = da.stop_sql_logging() or ()
542@@ -483,18 +494,6 @@
543 new_html = ''.join(
544 (e_start, added_html, e_close_body, e_end))
545 request.response.setResult(new_html)
546- # Dump memory profiling info.
547- if _profilers.memory_profile_start is not None:
548- log = file(config.profiling.memory_profile_log, 'a')
549- vss_start, rss_start = _profilers.memory_profile_start
550- _profilers.memory_profile_start = None
551- vss_end, rss_end = memory(), resident()
552- if oopsid is None:
553- oopsid = '-'
554- log.write('%s %s %s %f %d %d %d %d\n' % (
555- timestamp, pageid, oopsid, da.get_request_duration(),
556- vss_start, rss_start, vss_end, rss_end))
557- log.close()
558
559
560 def get_desired_profile_actions(request):
561
562=== modified file 'lib/lp/services/profile/tests.py'
563--- lib/lp/services/profile/tests.py 2011-09-01 16:25:05 +0000
564+++ lib/lp/services/profile/tests.py 2011-10-03 14:45:28 +0000
565@@ -134,7 +134,7 @@
566
567 def assertCleanProfilerState(self, message='something did not clean up'):
568 """Check whether profiler thread local is clean."""
569- for name in ('profiler', 'actions', 'memory_profile_start'):
570+ for name in ('profiler', 'actions'):
571 self.assertIs(
572 getattr(profile._profilers, name, None), None,
573 'Profiler state (%s) is dirty; %s.' % (name, message))
574@@ -159,7 +159,6 @@
575 profile._profilers.profiler.stop()
576 profile._profilers.profiler = None
577 profile._profilers.actions = None
578- profile._profilers.memory_profile_start = None
579 profile._profilers.profiling = False
580 super(TestCleanupProfiler, self).tearDown()
581
582@@ -186,10 +185,10 @@
583 # request.
584 self.pushProfilingConfig(profiling_allowed='True')
585 profile.start_request(self._get_start_event('/'))
586- self.assertEqual(profile._profilers.actions, {})
587+ self.assertFalse(profile._profilers.profiling)
588 self.assertIs(getattr(profile._profilers, 'profiler', None), None)
589 self.assertIs(
590- getattr(profile._profilers, 'memory_profile_start', None), None)
591+ getattr(profile._profilers, 'actions', None), None)
592
593 def test_optional_profiling_with_show_request_starts_profiling(self):
594 # If profiling is allowed and a request with the "show" marker
595@@ -197,9 +196,6 @@
596 self.pushProfilingConfig(profiling_allowed='True')
597 profile.start_request(self._get_start_event('/++profile++show/'))
598 self.assertIsInstance(profile._profilers.profiler, profile.Profiler)
599- self.assertIs(
600- getattr(profile._profilers, 'memory_profile_start', None),
601- None)
602 self.assertEquals(set(profile._profilers.actions), set(('show', )))
603
604 def test_optional_profiling_with_callgrind_request_starts_profiling(self):
605@@ -208,9 +204,6 @@
606 self.pushProfilingConfig(profiling_allowed='True')
607 profile.start_request(self._get_start_event('/++profile++callgrind/'))
608 self.assertIsInstance(profile._profilers.profiler, profile.Profiler)
609- self.assertIs(
610- getattr(profile._profilers, 'memory_profile_start', None),
611- None)
612 self.assertEquals(
613 set(profile._profilers.actions), set(('callgrind', )))
614
615@@ -220,9 +213,6 @@
616 self.pushProfilingConfig(profiling_allowed='True')
617 profile.start_request(self._get_start_event('/++profile++log/'))
618 self.assertIsInstance(profile._profilers.profiler, profile.Profiler)
619- self.assertIs(
620- getattr(profile._profilers, 'memory_profile_start', None),
621- None)
622 self.assertEquals(
623 set(profile._profilers.actions), set(('callgrind', )))
624
625@@ -233,9 +223,6 @@
626 profile.start_request(
627 self._get_start_event('/++profile++callgrind&show/'))
628 self.assertIsInstance(profile._profilers.profiler, profile.Profiler)
629- self.assertIs(
630- getattr(profile._profilers, 'memory_profile_start', None),
631- None)
632 self.assertEquals(
633 set(profile._profilers.actions), set(('callgrind', 'show')))
634
635@@ -249,9 +236,6 @@
636 profile.start_request(
637 self._get_start_event('/++profile++show&callgrind'))
638 self.assertIsInstance(profile._profilers.profiler, profile.Profiler)
639- self.assertIs(
640- getattr(profile._profilers, 'memory_profile_start', None),
641- None)
642 self.assertEquals(
643 set(profile._profilers.actions), set(('callgrind', 'show')))
644
645@@ -263,9 +247,6 @@
646 self._get_start_event('/++profile++pstats/'))
647 self.assertIsInstance(profile._profilers.profiler,
648 profile.Profiler)
649- self.assertIs(
650- getattr(profile._profilers, 'memory_profile_start', None),
651- None)
652 self.assertEquals(set(profile._profilers.actions), set(('pstats',)))
653
654 def test_optional_profiling_with_log_pstats(self):
655@@ -276,9 +257,6 @@
656 profile.start_request(
657 self._get_start_event('/++profile++log&pstats/'))
658 self.assertIsInstance(profile._profilers.profiler, profile.Profiler)
659- self.assertIs(
660- getattr(profile._profilers, 'memory_profile_start', None),
661- None)
662 self.assertEquals(
663 set(profile._profilers.actions), set(('callgrind', 'pstats',)))
664
665@@ -291,9 +269,6 @@
666 self._get_start_event('/++profile++pstats&callgrind/'))
667 self.assertIsInstance(profile._profilers.profiler,
668 profile.Profiler)
669- self.assertIs(
670- getattr(profile._profilers, 'memory_profile_start', None),
671- None)
672 self.assertEquals(
673 set(profile._profilers.actions), set(('pstats', 'callgrind')))
674
675@@ -303,9 +278,6 @@
676 profiling_allowed='True', profile_all_requests='True')
677 profile.start_request(self._get_start_event('/'))
678 self.assertIsInstance(profile._profilers.profiler, profile.Profiler)
679- self.assertIs(
680- getattr(profile._profilers, 'memory_profile_start', None),
681- None)
682 self.assertEquals(
683 set(profile._profilers.actions), set(('callgrind', )))
684
685@@ -315,9 +287,6 @@
686 self.pushProfilingConfig(profiling_allowed='True')
687 profile.start_request(self._get_start_event('/++profile++/'))
688 self.assertIs(getattr(profile._profilers, 'profiler', None), None)
689- self.assertIs(
690- getattr(profile._profilers, 'memory_profile_start', None),
691- None)
692 self.assertEquals(set(profile._profilers.actions), set(('help', )))
693
694 def test_forced_profiling_with_wrong_request_helps(self):
695@@ -327,9 +296,6 @@
696 profiling_allowed='True', profile_all_requests='True')
697 profile.start_request(self._get_start_event('/++profile++/'))
698 self.assertIsInstance(profile._profilers.profiler, profile.Profiler)
699- self.assertIs(
700- getattr(profile._profilers, 'memory_profile_start', None),
701- None)
702 self.assertEquals(
703 set(profile._profilers.actions), set(('help', 'callgrind')))
704
705@@ -338,18 +304,20 @@
706 profiling_allowed='True', memory_profile_log='.')
707 profile.start_request(self._get_start_event('/'))
708 self.assertIs(getattr(profile._profilers, 'profiler', None), None)
709- self.assertIsInstance(profile._profilers.memory_profile_start, tuple)
710- self.assertEqual(len(profile._profilers.memory_profile_start), 2)
711- self.assertEqual(profile._profilers.actions, {})
712+ actions = profile._profilers.actions
713+ self.assertEqual(set(actions), set(['memory_profile_start']))
714+ self.assertIsInstance(actions['memory_profile_start'], tuple)
715+ self.assertEqual(len(actions['memory_profile_start']), 2)
716
717 def test_combo_memory_and_profile_start(self):
718 self.pushProfilingConfig(
719 profiling_allowed='True', memory_profile_log='.')
720 profile.start_request(self._get_start_event('/++profile++show/'))
721 self.assertIsInstance(profile._profilers.profiler, profile.Profiler)
722- self.assertIsInstance(profile._profilers.memory_profile_start, tuple)
723- self.assertEqual(len(profile._profilers.memory_profile_start), 2)
724- self.assertEquals(set(profile._profilers.actions), set(('show', )))
725+ actions = profile._profilers.actions
726+ self.assertEqual(set(actions), set(['memory_profile_start', 'show']))
727+ self.assertIsInstance(actions['memory_profile_start'], tuple)
728+ self.assertEqual(len(actions['memory_profile_start']), 2)
729
730 def test_sqltrace_start(self):
731 self.pushProfilingConfig(profiling_allowed='True')
732@@ -712,7 +680,8 @@
733 self.assertIsInstance(
734 profile._profilers.profiler, profile.Profiler)
735 self.assertEquals(
736- set(('show', 'callgrind')), set(profile._profilers.actions))
737+ set(('show', 'callgrind', 'memory_profile_start')),
738+ set(profile._profilers.actions))
739
740
741 class TestInlineProfiling(BaseRequestEndHandlerTest):
742
743=== modified file 'lib/lp/testing/fixture.py'
744--- lib/lp/testing/fixture.py 2011-10-03 00:24:04 +0000
745+++ lib/lp/testing/fixture.py 2011-10-03 14:45:28 +0000
746@@ -5,6 +5,8 @@
747
748 __metaclass__ = type
749 __all__ = [
750+ 'PGBouncerFixture',
751+ 'Urllib2Fixture',
752 'ZopeAdapterFixture',
753 'ZopeEventHandlerFixture',
754 'ZopeViewReplacementFixture',
755@@ -18,6 +20,14 @@
756 Fixture,
757 )
758 import pgbouncer.fixture
759+from wsgi_intercept import (
760+ add_wsgi_intercept,
761+ remove_wsgi_intercept,
762+ )
763+from wsgi_intercept.urllib2_intercept import (
764+ install_opener,
765+ uninstall_opener,
766+ )
767 from zope.component import (
768 getGlobalSiteManager,
769 provideHandler,
770@@ -173,3 +183,20 @@
771 self.gsm.adapters.register(
772 (self.context_interface, self.request_interface), Interface,
773 self.name, self.original)
774+
775+
776+class Urllib2Fixture(Fixture):
777+ """Let tests use urllib to connect to an in-process Launchpad.
778+
779+ Initially this only supports connecting to launchpad.dev because
780+ that is all that is needed. Later work could connect all
781+ sub-hosts (e.g. bugs.launchpad.dev)."""
782+
783+ def setUp(self):
784+ # Work around circular import.
785+ from canonical.testing.layers import wsgi_application
786+ super(Urllib2Fixture, self).setUp()
787+ add_wsgi_intercept('launchpad.dev', 80, lambda: wsgi_application)
788+ self.addCleanup(remove_wsgi_intercept, 'launchpad.dev', 80)
789+ install_opener()
790+ self.addCleanup(uninstall_opener)