Merge lp:~henninge/launchpad/bug-461818 into lp:launchpad

Proposed by Henning Eggers
Status: Merged
Approved by: Henning Eggers
Approved revision: no longer in the source branch.
Merged at revision: not available
Proposed branch: lp:~henninge/launchpad/bug-461818
Merge into: lp:launchpad
Diff against target: 431 lines
5 files modified
lib/lp/registry/model/product.py (+2/-2)
lib/lp/services/database/configure.zcml (+1/-1)
lib/lp/services/database/prejoin.py (+9/-11)
lib/lp/services/database/tests/test_prejoin.py (+33/-26)
lib/lp/translations/model/potemplate.py (+66/-63)
To merge this branch: bzr merge lp:~henninge/launchpad/bug-461818
Reviewer Review Type Date Requested Status
Jeroen T. Vermeulen (community) code Approve
Review via email: mp+14085@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Henning Eggers (henninge) wrote :

= Bug 461818 =

The +templates page for distribution series was timing out with lots of storm objects being created by single-row SourcePackageName queries. This happened because the list of source packages for an Ubuntu distribution is quite long, obviously, and displays a SourcePackageName.name per line.

== Proposed solution ==

Prejoin SourcePackageName when querying the POTemplates for the list. That will prevent storm from fetching each SourcePackageName in its own query when it is referenced in the page template. The query happens in POTemplageSubSet.

== Implementation details ==

While the solution is quite straight forward, the implementation became a bit more complex because I decided to convert POTemplageSubSet to use storm and to do some clean-ups.

I made use of the "precache" function for storm which I renamed to "prejoin" as Stuart had suggested. You will find the result of this in the first 4 files of the patch. It also fixes a bug in prejoin that made it fail when "one()" was used on None which happens when the result set is empty. Really, I think storm should be returning an EmptyResultSet, but that is not the subject here.

The next (5th) file is model/potemplate.py which has the meat of this branch. Note that I moved the actual construction of the storm query into its own method so that only the base clauses are collected in the constructor. Note also, how I got rid of the useless sorting by path. The rest of the file translates quite nicely into storm.

One more point to note in this file is getAllOrderByDateLastUpdated which used to reconstruct its own clauses with the difference that it did not take from_sourcepackage into account. I replaced that with the standard query for the SubSet (which does prefer from_sourcepackage). This did not break any tests but I am not all sure about the consequences.

== Test ==

I ran all of lp.translations on it because POTemplateSubSet is so widely used.

bin/test -vvt lp.translations -t prejoin

== Demo/QA ==

There should be no more timeouts on this page:
https://translations.staging.launchpad.net/ubuntu/karmic/+templates

= Launchpad lint =

Checking for conflicts. and issues in doctests and templates.
Running jslint, xmllint, pyflakes, and pylint.
Using normal rules.

Linting changed files:
  lib/lp/registry/model/product.py
  lib/lp/services/database/configure.zcml
  lib/lp/services/database/prejoin.py
  lib/lp/services/database/tests/test_prejoin.py
  lib/lp/translations/model/potemplate.py

== Pylint notices ==

lib/lp/registry/model/product.py
    29: [F0401] Unable to import 'lazr.delegates' (No module named delegates)

lib/lp/services/database/prejoin.py
    11: [F0401] Unable to import 'lazr.delegates' (No module named delegates)

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

Good branch overall, with some much-needed cleanup. A few changes discussed on IRC:

 * just_for_count is ugly; replace with do_prefetch and use the existing ordering parameter if necessary.

 * A comment needed work.

 * It'd be nice, though not absolutely necessary at this point, to have the SQL conditions rewritten in Storm.

Jeroen

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/registry/model/product.py'
2--- lib/lp/registry/model/product.py 2009-10-16 15:00:55 +0000
3+++ lib/lp/registry/model/product.py 2009-10-28 16:28:12 +0000
4@@ -63,7 +63,7 @@
5 from lp.registry.model.productlicense import ProductLicense
6 from lp.registry.model.productrelease import ProductRelease
7 from lp.registry.model.productseries import ProductSeries
8-from lp.services.database.precache import precache
9+from lp.services.database.prejoin import prejoin
10 from lp.answers.model.question import (
11 QuestionTargetSearch, QuestionTargetMixin)
12 from lp.blueprints.model.specification import (
13@@ -1293,7 +1293,7 @@
14
15 # We only want Product - the other tables are just to populate
16 # the cache.
17- return precache(results)
18+ return prejoin(results)
19
20 def featuredTranslatables(self, maximumproducts=8):
21 """See `IProductSet`"""
22
23=== modified file 'lib/lp/services/database/configure.zcml'
24--- lib/lp/services/database/configure.zcml 2009-07-17 02:25:09 +0000
25+++ lib/lp/services/database/configure.zcml 2009-10-28 16:28:12 +0000
26@@ -8,7 +8,7 @@
27 xmlns:i18n="http://namespaces.zope.org/i18n"
28 i18n_domain="launchpad">
29
30- <class class="lp.services.database.precache.PrecacheResultSet">
31+ <class class="lp.services.database.prejoin.PrejoinResultSet">
32 <allow interface="storm.zope.interfaces.IResultSet" />
33 <allow attributes="__getslice__" />
34 </class>
35
36=== renamed file 'lib/lp/services/database/precache.py' => 'lib/lp/services/database/prejoin.py'
37--- lib/lp/services/database/precache.py 2009-10-05 11:33:04 +0000
38+++ lib/lp/services/database/prejoin.py 2009-10-28 16:28:12 +0000
39@@ -1,21 +1,17 @@
40 # Copyright 2009 Canonical Ltd. This software is licensed under the
41 # GNU Affero General Public License version 3 (see the file LICENSE).
42
43-"""precache for Storm.
44-
45-XXX stub 2009-06-18 bug=388798: This feature may be misnamed, should be
46-moved to a better location, and needs tests.
47-"""
48+"""Prejoin for Storm."""
49
50 __metaclass__ = type
51-__all__ = ['precache']
52+__all__ = ['prejoin']
53
54 from storm.zope.interfaces import IResultSet
55
56 from lazr.delegates import delegates
57
58-class PrecacheResultSet:
59- """Precache support.
60+class PrejoinResultSet:
61+ """Prejoin support.
62
63 Wrap a ResultSet, trimming unwanted items. The unwanted items
64 are still pulled from the database and populate the Storm caches.
65@@ -25,7 +21,7 @@
66 The preferred solution is support in Storm core, so we can just do
67 something like:
68
69- >>> results = store.find(Product).precache(
70+ >>> results = store.find(Product).prejoin(
71 ... (Person, EmailAddress),
72 ... Product._ownerID == Person.id,
73 ... EmailAddress.personID == Person.id)
74@@ -36,9 +32,11 @@
75 self.return_slice = return_slice
76
77 def _chain(self, result_set):
78- return PrecacheResultSet(result_set, self.return_slice)
79+ return PrejoinResultSet(result_set, self.return_slice)
80
81 def _chomp(self, row):
82+ if row is None:
83+ return None
84 elems = row[self.return_slice]
85 if len(elems) == 1:
86 return elems[0]
87@@ -113,4 +111,4 @@
88 """See `IResultSet`."""
89 raise NotImplementedError("cached")
90
91-precache = PrecacheResultSet
92+prejoin = PrejoinResultSet
93
94=== renamed file 'lib/lp/services/database/tests/test_precache.py' => 'lib/lp/services/database/tests/test_prejoin.py'
95--- lib/lp/services/database/tests/test_precache.py 2009-10-05 11:33:04 +0000
96+++ lib/lp/services/database/tests/test_prejoin.py 2009-10-28 16:28:12 +0000
97@@ -1,24 +1,22 @@
98 # Copyright 2009 Canonical Ltd. This software is licensed under the
99 # GNU Affero General Public License version 3 (see the file LICENSE).
100
101-"""precache module tests."""
102+"""prejoin module tests."""
103
104 __metaclass__ = type
105 __all__ = []
106
107 import unittest
108
109-from storm.expr import And, Join, LeftJoin
110-
111 from canonical.launchpad.interfaces import IMasterStore
112 from canonical.testing.layers import LaunchpadZopelessLayer
113 from lp.registry.model.person import Person
114 from lp.registry.model.product import Product
115-from lp.services.database.precache import precache, PrecacheResultSet
116+from lp.services.database.prejoin import prejoin, PrejoinResultSet
117 from lp.testing import TestCase
118
119
120-class PrecacheTestCase(TestCase):
121+class PrejoinTestCase(TestCase):
122 layer = LaunchpadZopelessLayer
123
124 def setUp(self):
125@@ -27,73 +25,82 @@
126 # All products
127 self.standard_result = self.store.find(Product).order_by(Product.name)
128
129- # All products, with owner and preferred email address precached.
130+ # All products, with owner and preferred email address prejoined.
131 # Note that because some Product owners have multiple email
132- # addresses, this query returns more results. precache needs
133+ # addresses, this query returns more results. prejoin needs
134 # to hide this from callsites.
135 self.unwrapped_result = self.store.find(
136 (Product, Person),
137 Product._ownerID == Person.id).order_by(Product.name)
138- self.precache_result = precache(self.unwrapped_result)
139+ self.prejoin_result = prejoin(self.unwrapped_result)
140
141- def verify(self, precached, normal):
142- # Ensure our precached result really is a PrecacheResultSet.
143+ def verify(self, prejoined, normal):
144+ # Ensure our prejoined result really is a PrejoinResultSet.
145 # One of our methods might not be sticky, and we have ended up
146 # with a normal Storm ResultSet.
147 self.assertTrue(
148- isinstance(precached, PrecacheResultSet),
149- "Expected a PrecacheResultSet, got %s" % repr(precached))
150+ isinstance(prejoined, PrejoinResultSet),
151+ "Expected a PrejoinResultSet, got %s" % repr(prejoined))
152
153 # Confirm the two result sets return identical results.
154- self.assertEqual(list(normal), list(precached))
155+ self.assertEqual(list(normal), list(prejoined))
156
157 def test_count(self):
158 self.assertEqual(
159 self.standard_result.count(),
160- self.precache_result.count())
161+ self.prejoin_result.count())
162
163 def test_copy(self):
164- copy = self.precache_result.copy()
165+ copy = self.prejoin_result.copy()
166 self.verify(copy, self.standard_result)
167
168 def test_config(self):
169- self.precache_result.config(offset=3, limit=2)
170+ self.prejoin_result.config(offset=3, limit=2)
171 self.standard_result.config(offset=3, limit=2)
172- self.verify(self.precache_result, self.standard_result)
173+ self.verify(self.prejoin_result, self.standard_result)
174
175 def test_config_returnvalue(self):
176- precache_rv = self.precache_result.config(offset=3, limit=2)
177+ prejoin_rv = self.prejoin_result.config(offset=3, limit=2)
178 standard_rv = self.standard_result.config(offset=3, limit=2)
179- self.verify(precache_rv, standard_rv)
180+ self.verify(prejoin_rv, standard_rv)
181
182 def test_order_by(self):
183 self.verify(
184- self.precache_result.order_by(Product.id),
185+ self.prejoin_result.order_by(Product.id),
186 self.standard_result.order_by(Product.id))
187
188 def test_slice(self):
189 self.verify(
190- self.precache_result[4:6],
191+ self.prejoin_result[4:6],
192 self.standard_result[4:6])
193
194 def test_any(self):
195- self.assertIn(self.precache_result.any(), self.standard_result)
196+ self.assertIn(self.prejoin_result.any(), self.standard_result)
197
198 def test_first(self):
199 self.assertEqual(
200- self.standard_result.first(), self.precache_result.first())
201+ self.standard_result.first(), self.prejoin_result.first())
202
203 def test_one(self):
204 standard_result = self.store.find(Product, Product.name == 'firefox')
205- precache_result = precache(self.store.find(
206+ prejoin_result = prejoin(self.store.find(
207 (Product, Person),
208 Person.id == Product._ownerID,
209 Product.name == 'firefox'))
210- self.assertEqual(standard_result.one(), precache_result.one())
211+ self.assertEqual(standard_result.one(), prejoin_result.one())
212+
213+ def test_one_empty(self):
214+ # For an empty result (None), one returns None, too.
215+ name = "none-existent-name"
216+ prejoin_result = prejoin(self.store.find(
217+ (Product, Person),
218+ Person.id == Product._ownerID,
219+ Product.name == name))
220+ self.assertIs(None, prejoin_result.one())
221
222 def test_cache_populated(self):
223 # Load a row.
224- product = self.precache_result.first()
225+ product = self.prejoin_result.first()
226
227 # Destroy our data, without telling Storm. This way we can
228 # tell if we are accessing an object from the cache, or if it
229
230=== modified file 'lib/lp/translations/model/potemplate.py'
231--- lib/lp/translations/model/potemplate.py 2009-10-27 10:40:46 +0000
232+++ lib/lp/translations/model/potemplate.py 2009-10-28 16:28:12 +0000
233@@ -24,7 +24,7 @@
234 from sqlobject import (
235 BoolCol, ForeignKey, IntCol, SQLMultipleJoin, SQLObjectNotFound,
236 StringCol)
237-from storm.expr import Alias, And, Join, LeftJoin, Or, SQL
238+from storm.expr import Alias, And, Desc, Join, LeftJoin, Or, SQL
239 from storm.info import ClassAlias
240 from storm.store import Store
241 from zope.component import getAdapter, getUtility
242@@ -40,8 +40,10 @@
243 from canonical.launchpad import helpers
244 from canonical.launchpad.interfaces.lpstorm import IStore
245 from lp.translations.utilities.rosettastats import RosettaStats
246+from lp.services.database.prejoin import prejoin
247 from lp.services.worlddata.model.language import Language
248 from lp.registry.interfaces.person import validate_public_person
249+from lp.registry.model.sourcepackagename import SourcePackageName
250 from lp.translations.model.pofile import POFile, DummyPOFile
251 from lp.translations.model.pomsgid import POMsgID
252 from lp.translations.model.potmsgset import POTMsgSet
253@@ -1002,8 +1004,8 @@
254 self.distroseries = distroseries
255 self.productseries = productseries
256 self.iscurrent = iscurrent
257- self.clausetables = []
258- self.orderby = ['id']
259+ self.orderby = [POTemplate.id]
260+ self.clauses = []
261
262 assert productseries is None or distroseries is None, (
263 'A product series must not be used with a distro series.')
264@@ -1011,46 +1013,63 @@
265 assert productseries is not None or distroseries is not None, (
266 'Either productseries or distroseries must be not None.')
267
268+ # Construct the base clauses.
269 if productseries is not None:
270- self.query = ('POTemplate.productseries = %s' %
271- sqlvalues(productseries.id))
272- elif distroseries is not None and from_sourcepackagename is not None:
273- self.query = ('POTemplate.from_sourcepackagename = %s AND'
274- ' POTemplate.distroseries = %s ' %
275- sqlvalues(from_sourcepackagename.id,
276- distroseries.id))
277- self.sourcepackagename = from_sourcepackagename
278- elif distroseries is not None and sourcepackagename is not None:
279- self.query = ('POTemplate.sourcepackagename = %s AND'
280- ' POTemplate.distroseries = %s ' %
281- sqlvalues(sourcepackagename.id, distroseries.id))
282+ self.clauses.append(
283+ POTemplate.productseriesID == productseries.id)
284 else:
285- self.query = (
286- 'POTemplate.distroseries = DistroSeries.id AND'
287- ' DistroSeries.id = %s' % sqlvalues(distroseries.id))
288- self.orderby.append('DistroSeries.name')
289- self.clausetables.append('DistroSeries')
290-
291+ self.clauses.append(
292+ POTemplate.distroseriesID == distroseries.id)
293+ if from_sourcepackagename is not None:
294+ self.clauses.append(
295+ POTemplate.from_sourcepackagenameID ==
296+ from_sourcepackagename.id)
297+ self.sourcepackagename = from_sourcepackagename
298+ elif sourcepackagename is not None:
299+ self.clauses.append(
300+ POTemplate.sourcepackagename == sourcepackagename.id)
301+ else:
302+ # Select all POTemplates in a Distroseries.
303+ pass
304 # Add the filter for the iscurrent flag if requested.
305 if iscurrent is not None:
306- self.query += " AND POTemplate.iscurrent=%s" % (
307- sqlvalues(iscurrent))
308-
309- # Finally, we sort the query by its path in all cases.
310- self.orderby.append('POTemplate.path')
311+ self.clauses.append(
312+ POTemplate.iscurrent == iscurrent)
313+
314+ def _build_query(self, additional_clause=None,
315+ ordered=True, do_prejoin=True):
316+ """Construct the storm query."""
317+ if additional_clause is None:
318+ condition = And(self.clauses)
319+ else:
320+ condition = And(self.clauses + [additional_clause])
321+
322+ if self.productseries is not None:
323+ store = Store.of(self.productseries)
324+ query = store.find(POTemplate, condition)
325+ else:
326+ store = Store.of(self.distroseries)
327+ if do_prejoin:
328+ query = prejoin(store.find(
329+ (POTemplate, SourcePackageName),
330+ (POTemplate.sourcepackagenameID ==
331+ SourcePackageName.id), condition))
332+ else:
333+ query = store.find(POTemplate, condition)
334+
335+ if ordered:
336+ return query.order_by(self.orderby)
337+ return query
338
339 def __iter__(self):
340 """See `IPOTemplateSubset`."""
341- res = POTemplate.select(self.query, clauseTables=self.clausetables,
342- orderBy=self.orderby)
343-
344- for potemplate in res:
345+ for potemplate in self._build_query():
346 yield potemplate
347
348 def __len__(self):
349 """See `IPOTemplateSubset`."""
350- res = POTemplate.select(self.query, clauseTables=self.clausetables)
351- return res.count()
352+ result = self._build_query(do_prejoin=False, ordered=False)
353+ return result.count()
354
355 def __getitem__(self, name):
356 """See `IPOTemplateSubset`."""
357@@ -1109,24 +1128,16 @@
358
359 def getPOTemplateByName(self, name):
360 """See `IPOTemplateSubset`."""
361- queries = [self.query]
362- clausetables = list(self.clausetables)
363- queries.append('POTemplate.name = %s' % sqlvalues(name))
364-
365- return POTemplate.selectOne(' AND '.join(queries),
366- clauseTables=clausetables)
367+ result = self._build_query(POTemplate.name == name, ordered=False)
368+ return result.one()
369
370 def getPOTemplateByTranslationDomain(self, translation_domain):
371 """See `IPOTemplateSubset`."""
372- queries = [self.query]
373- clausetables = list(self.clausetables)
374-
375- queries.append('POTemplate.translation_domain = %s' % sqlvalues(
376- translation_domain))
377+ query_result = self._build_query(
378+ POTemplate.translation_domain == translation_domain)
379
380 # Fetch up to 2 templates, to check for duplicates.
381- matches = POTemplate.select(
382- ' AND '.join(queries), clauseTables=clausetables, limit=2)
383+ matches = query_result.config(limit=2)
384
385 result = [match for match in matches]
386 if len(result) == 0:
387@@ -1144,23 +1155,14 @@
388
389 def getPOTemplateByPath(self, path):
390 """See `IPOTemplateSubset`."""
391- query = '%s AND POTemplate.path = %s' % (self.query, quote(path))
392-
393- return POTemplate.selectOne(query, clauseTables=self.clausetables)
394+ result = self._build_query(
395+ POTemplate.path == path, ordered=False)
396+ return result.one()
397
398 def getAllOrderByDateLastUpdated(self):
399 """See `IPOTemplateSet`."""
400- query = []
401- if self.productseries is not None:
402- query.append('productseries = %s' % sqlvalues(self.productseries))
403- if self.distroseries is not None:
404- query.append('distroseries = %s' % sqlvalues(self.distroseries))
405- if self.sourcepackagename is not None:
406- query.append('sourcepackagename = %s' % sqlvalues(
407- self.sourcepackagename))
408-
409- return POTemplate.select(
410- ' AND '.join(query), orderBy=['-date_last_updated'])
411+ result = self._build_query(ordered=False)
412+ return result.order_by(Desc(POTemplate.date_last_updated))
413
414 def getClosestPOTemplate(self, path):
415 """See `IPOTemplateSubset`."""
416@@ -1190,10 +1192,11 @@
417
418 def findUniquePathlessMatch(self, filename):
419 """See `IPOTemplateSubset`."""
420- query = self.query + (
421- " AND (POTemplate.path = %s OR POTemplate.path LIKE '%%/' || %s)"
422- % (quote(filename), quote_like(filename)))
423- candidates = list(POTemplate.select(query, limit=2))
424+ result = self._build_query(
425+ ("(POTemplate.path = %s OR POTemplate.path LIKE '%%%%/' || %s)"
426+ % (quote(filename), quote_like(filename))),
427+ ordered=False)
428+ candidates = list(result.config(limit=2))
429
430 if len(candidates) == 1:
431 # Found exactly one match.