Merge lp:~lifeless/launchpad/bug-618367 into lp:launchpad

Proposed by Robert Collins
Status: Merged
Approved by: Robert Collins
Approved revision: no longer in the source branch.
Merged at revision: 11483
Proposed branch: lp:~lifeless/launchpad/bug-618367
Merge into: lp:launchpad
Diff against target: 419 lines (+246/-42)
5 files modified
lib/lp/blueprints/browser/tests/test_views.py (+78/-5)
lib/lp/blueprints/model/specification.py (+78/-0)
lib/lp/registry/model/distribution.py (+9/-9)
lib/lp/registry/model/person.py (+72/-19)
lib/lp/registry/model/product.py (+9/-9)
To merge this branch: bzr merge lp:~lifeless/launchpad/bug-618367
Reviewer Review Type Date Requested Status
Michael Nelson (community) code Approve
Review via email: mp+34162@code.launchpad.net

Commit message

Preload is_valid_person for +assignments on Product and Distribution.

Description of the change

Preload is_valid_person for +assignments on Product and Distribution, should fix bug 618367, according to the observed oopses. It will probably still be slow, but 6 seconds faster.

To post a comment you must log in.
Revision history for this message
Michael Nelson (michael.nelson) wrote :
Download full text (10.8 KiB)

Looks great Robert.

I've got a few questions below, more for my own understanding. And you said you'd update _validity_queries() to return a dict or namedtuple.

Thanks!

> === modified file 'lib/lp/blueprints/browser/tests/test_views.py'
> --- lib/lp/blueprints/browser/tests/test_views.py 2010-08-20 20:31:18 +0000
> +++ lib/lp/blueprints/browser/tests/test_views.py 2010-08-31 07:46:51 +0000
> @@ -9,19 +9,92 @@
> import os
> import unittest
>
> +from storm.store import Store
> +from testtools.matchers import LessThan
> +
> from canonical.launchpad.testing.systemdocs import (
> LayeredDocFileSuite,
> setUp,
> tearDown,
> )
> +from canonical.launchpad.webapp import canonical_url
> from canonical.testing import DatabaseFunctionalLayer
> -
> -
> -here = os.path.dirname(os.path.realpath(__file__))
> -
> +from lp.testing import (
> + login,
> + logout,
> + TestCaseWithFactory,
> + )
> +from lp.testing.matchers import HasQueryCount
> +from lp.testing.sampledata import ADMIN_EMAIL
> +from lp.testing._webservice import QueryCollector

Should this be exported from lp.testing.__init__.py instead of importing from
_webservice?

> +
> +
> +class TestAssignments(TestCaseWithFactory):
> +
> + layer = DatabaseFunctionalLayer
> +
> + def invalidate_and_render(self, browser, dbobj, url):
> + # Ensure caches have been flushed.
> + store = Store.of(dbobj)
> + store.flush()
> + store.invalidate()
> + browser.open(url)
> +
> + def check_query_counts_scaling_with_unique_people(self,
> + target, targettype):
> + """Check that a particular hasSpecifications target scales well.
> +
> + :param target: A spec target like a product.
> + :param targettype: The parameter to pass to makeSpecification to
> + associate the target. e.g. 'product'.
> + """
> + query_baseline = 40
> + people = []
> + for _ in range(10):

Up to you, but JFYI, our style guide says:

Single character names should be avoided for both variables and methods. Even
list comprehensions and generator expressions should not use them.

> + people.append(self.factory.makePerson())
> + specs = []
> + for _ in range(10):

ditto.

> + specs.append(self.factory.makeSpecification(
> + **{targettype:target}))
> + collector = QueryCollector()
> + collector.register()
> + self.addCleanup(collector.unregister)
> + viewer = self.factory.makePerson(password="test")
> + browser = self.getUserBrowser(user=viewer)
> + url = canonical_url(target) + "/+assignments"
> + # Seed the cookie cache and any other cross-request state we may gain
> + # in future. See canonical.launchpad.webapp.serssion: _get_secret.
> + browser.open(url)
> + self.invalidate_and_render(browser, target, url)
> + # Set a baseline
> + self.assertThat(collector, HasQueryCount(LessThan(query_baseline)))
> + no_assignees_count = collector.count
> + # Assign many unique people, which shouldn't change the page queries.
> + # Due to storm bug 619017 additional...

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/blueprints/browser/tests/test_views.py'
2--- lib/lp/blueprints/browser/tests/test_views.py 2010-08-20 20:31:18 +0000
3+++ lib/lp/blueprints/browser/tests/test_views.py 2010-08-31 19:38:45 +0000
4@@ -9,19 +9,92 @@
5 import os
6 import unittest
7
8+from storm.store import Store
9+from testtools.matchers import LessThan
10+
11 from canonical.launchpad.testing.systemdocs import (
12 LayeredDocFileSuite,
13 setUp,
14 tearDown,
15 )
16+from canonical.launchpad.webapp import canonical_url
17 from canonical.testing import DatabaseFunctionalLayer
18-
19-
20-here = os.path.dirname(os.path.realpath(__file__))
21-
22+from lp.testing import (
23+ login,
24+ logout,
25+ TestCaseWithFactory,
26+ )
27+from lp.testing.matchers import HasQueryCount
28+from lp.testing.sampledata import ADMIN_EMAIL
29+from lp.testing._webservice import QueryCollector
30+
31+
32+class TestAssignments(TestCaseWithFactory):
33+
34+ layer = DatabaseFunctionalLayer
35+
36+ def invalidate_and_render(self, browser, dbobj, url):
37+ # Ensure caches have been flushed.
38+ store = Store.of(dbobj)
39+ store.flush()
40+ store.invalidate()
41+ browser.open(url)
42+
43+ def check_query_counts_scaling_with_unique_people(self,
44+ target, targettype):
45+ """Check that a particular hasSpecifications target scales well.
46+
47+ :param target: A spec target like a product.
48+ :param targettype: The parameter to pass to makeSpecification to
49+ associate the target. e.g. 'product'.
50+ """
51+ query_baseline = 40
52+ people = []
53+ for _ in range(10):
54+ people.append(self.factory.makePerson())
55+ specs = []
56+ for _ in range(10):
57+ specs.append(self.factory.makeSpecification(
58+ **{targettype:target}))
59+ collector = QueryCollector()
60+ collector.register()
61+ self.addCleanup(collector.unregister)
62+ viewer = self.factory.makePerson(password="test")
63+ browser = self.getUserBrowser(user=viewer)
64+ url = canonical_url(target) + "/+assignments"
65+ # Seed the cookie cache and any other cross-request state we may gain
66+ # in future. See canonical.launchpad.webapp.serssion: _get_secret.
67+ browser.open(url)
68+ self.invalidate_and_render(browser, target, url)
69+ # Set a baseline
70+ self.assertThat(collector, HasQueryCount(LessThan(query_baseline)))
71+ no_assignees_count = collector.count
72+ # Assign many unique people, which shouldn't change the page queries.
73+ # Due to storm bug 619017 additional queries can be triggered when
74+ # revalidating people, so we allow -some- fuzz.
75+ login(ADMIN_EMAIL)
76+ for person, spec in zip(people, specs):
77+ spec.assignee = person
78+ logout()
79+ self.invalidate_and_render(browser, target, url)
80+ self.assertThat(
81+ collector,
82+ HasQueryCount(LessThan(no_assignees_count + 5)))
83+
84+ def test_product_query_counts_scale_below_unique_people(self):
85+ self.check_query_counts_scaling_with_unique_people(
86+ self.factory.makeProduct(),
87+ 'product')
88+
89+ def test_distro_query_counts_scale_below_unique_people(self):
90+ self.check_query_counts_scaling_with_unique_people(
91+ self.factory.makeDistribution(),
92+ 'distribution')
93+
94
95 def test_suite():
96- suite = unittest.TestSuite()
97+ suite = unittest.TestLoader().loadTestsFromName(__name__)
98+ here = os.path.dirname(os.path.realpath(__file__))
99 testsdir = os.path.abspath(here)
100
101 # Add tests using default setup/teardown
102
103=== modified file 'lib/lp/blueprints/model/specification.py'
104--- lib/lp/blueprints/model/specification.py 2010-08-20 20:31:18 +0000
105+++ lib/lp/blueprints/model/specification.py 2010-08-31 19:38:45 +0000
106@@ -24,6 +24,14 @@
107 SQLRelatedJoin,
108 StringCol,
109 )
110+from storm.expr import (
111+ LeftJoin,
112+ )
113+from storm.locals import (
114+ ClassAlias,
115+ Desc,
116+ SQL,
117+ )
118 from storm.store import Store
119 from zope.event import notify
120 from zope.interface import implements
121@@ -40,6 +48,9 @@
122 SQLBase,
123 sqlvalues,
124 )
125+from canonical.launchpad.components.decoratedresultset import (
126+ DecoratedResultSet,
127+ )
128 from canonical.launchpad.helpers import (
129 get_contact_email_addresses,
130 shortlist,
131@@ -680,6 +691,73 @@
132 # this should be implemented by the actual context class
133 raise NotImplementedError
134
135+ def _specification_sort(self, sort):
136+ """Return the storm sort order for 'specifications'.
137+
138+ :param sort: As per HasSpecificationsMixin.specifications.
139+ """
140+ # sort by priority descending, by default
141+ if sort is None or sort == SpecificationSort.PRIORITY:
142+ return (
143+ Desc(Specification.priority), Specification.definition_status,
144+ Specification.name)
145+ elif sort == SpecificationSort.DATE:
146+ return (Desc(Specification.datecreated), Specification.id)
147+
148+ def _preload_specifications_people(self, query):
149+ """Perform eager loading of people and their validity for query.
150+
151+ :param query: a string query generated in the 'specifications'
152+ method.
153+ :return: A DecoratedResultSet with Person precaching setup.
154+ """
155+ # Circular import.
156+ from lp.registry.model.person import Person
157+ assignee = ClassAlias(Person, "assignee")
158+ approver = ClassAlias(Person, "approver")
159+ drafter = ClassAlias(Person, "drafter")
160+ origin = [Specification,
161+ LeftJoin(assignee, Specification.assignee==assignee.id),
162+ LeftJoin(approver, Specification.approver==approver.id),
163+ LeftJoin(drafter, Specification.drafter==drafter.id),
164+ ]
165+ columns = [Specification, assignee, approver, drafter]
166+ for alias in (assignee, approver, drafter):
167+ validity_info = Person._validity_queries(person_table=alias)
168+ origin.extend(validity_info["joins"])
169+ columns.extend(validity_info["tables"])
170+ # We only need one decorators list: all the decorators will be
171+ # bound the same, and having a single list lets us more easily call
172+ # the right one.
173+ decorators = validity_info["decorators"]
174+ columns = tuple(columns)
175+ results = Store.of(self).using(*origin).find(
176+ columns,
177+ SQL(query),
178+ )
179+ def cache_person(person, row, start_index):
180+ """apply caching decorators to person."""
181+ index = start_index
182+ for decorator in decorators:
183+ column = row[index]
184+ index += 1
185+ decorator(person, column)
186+ return index
187+ def cache_validity(row):
188+ # Assignee
189+ person = row[1]
190+ # After drafter
191+ index = 4
192+ index = cache_person(person, row, index)
193+ # approver
194+ person = row[2]
195+ index = cache_person(person, row, index)
196+ # drafter
197+ person = row[3]
198+ index = cache_person(person, row, index)
199+ return row[0]
200+ return DecoratedResultSet(results, cache_validity)
201+
202 @property
203 def valid_specifications(self):
204 """See IHasSpecifications."""
205
206=== modified file 'lib/lp/registry/model/distribution.py'
207--- lib/lp/registry/model/distribution.py 2010-08-24 15:29:01 +0000
208+++ lib/lp/registry/model/distribution.py 2010-08-31 19:38:45 +0000
209@@ -810,13 +810,7 @@
210 # defaults for informationalness: we don't have to do anything
211 # because the default if nothing is said is ANY
212
213- # sort by priority descending, by default
214- if sort is None or sort == SpecificationSort.PRIORITY:
215- order = (
216- ['-priority', 'Specification.definition_status',
217- 'Specification.name'])
218- elif sort == SpecificationSort.DATE:
219- order = ['-Specification.datecreated', 'Specification.id']
220+ order = self._specification_sort(sort)
221
222 # figure out what set of specifications we are interested in. for
223 # distributions, we need to be able to filter on the basis of:
224@@ -859,9 +853,15 @@
225 query += ' AND Specification.fti @@ ftq(%s) ' % quote(
226 constraint)
227
228- results = Specification.select(query, orderBy=order, limit=quantity)
229 if prejoin_people:
230- results = results.prejoin(['assignee', 'approver', 'drafter'])
231+ results = self._preload_specifications_people(query)
232+ else:
233+ results = Store.of(self).find(
234+ Specification,
235+ SQL(query))
236+ results.order_by(order)
237+ if quantity is not None:
238+ results = results[:quantity]
239 return results
240
241 def getSpecification(self, name):
242
243=== modified file 'lib/lp/registry/model/person.py'
244--- lib/lp/registry/model/person.py 2010-08-23 16:51:11 +0000
245+++ lib/lp/registry/model/person.py 2010-08-31 19:38:45 +0000
246@@ -1172,6 +1172,7 @@
247 @cachedproperty('_is_valid_person_cached')
248 def is_valid_person(self):
249 """See `IPerson`."""
250+ # This is prepopulated by various queries in and out of person.py.
251 if self.is_team:
252 return False
253 try:
254@@ -1577,6 +1578,67 @@
255 need_location=True, need_archive=True, need_preferred_email=True,
256 need_validity=True)
257
258+ @staticmethod
259+ def _validity_queries(person_table=None):
260+ """Return storm expressions and a decorator function for validity.
261+
262+ Preloading validity implies preloading preferred email addresses.
263+
264+ :param person_table: The person table to join to. Only supply if
265+ ClassAliases are in use.
266+ :return: A dict with four keys joins, tables, conditions, decorators
267+
268+ * joins are additional joins to use. e.g. [LeftJoin,LeftJoin]
269+ * tables are tables to use e.g. [EmailAddress, Account]
270+ * decorators are callbacks to call for each row. Each decorator takes
271+ (Person, column) where column is the column in the result set for that
272+ decorators type.
273+ """
274+ if person_table is None:
275+ person_table = Person
276+ email_table = EmailAddress
277+ account_table = Account
278+ else:
279+ email_table = ClassAlias(EmailAddress)
280+ account_table = ClassAlias(Account)
281+ origins = []
282+ columns = []
283+ decorators = []
284+ # Teams don't have email, so a left join
285+ origins.append(
286+ LeftJoin(email_table, And(
287+ email_table.personID == person_table.id,
288+ email_table.status == EmailAddressStatus.PREFERRED)))
289+ columns.append(email_table)
290+ origins.append(
291+ LeftJoin(account_table, And(
292+ person_table.accountID == account_table.id,
293+ account_table.status == AccountStatus.ACTIVE)))
294+ columns.append(account_table)
295+ def handleemail(person, column):
296+ #-- preferred email caching
297+ if not person:
298+ return
299+ email = column
300+ cache_property(person, '_preferredemail_cached', email)
301+ decorators.append(handleemail)
302+ def handleaccount(person, column):
303+ #-- validity caching
304+ if not person:
305+ return
306+ # valid if:
307+ valid = (
308+ # -- valid account found
309+ column is not None
310+ # -- preferred email found
311+ and person.preferredemail is not None)
312+ cache_property(person, '_is_valid_person_cached', valid)
313+ decorators.append(handleaccount)
314+ return dict(
315+ joins=origins,
316+ tables=columns,
317+ decorators=decorators)
318+
319 def _all_members(self, need_karma=False, need_ubuntu_coc=False,
320 need_location=False, need_archive=False, need_preferred_email=False,
321 need_validity=False):
322@@ -1606,6 +1668,7 @@
323 # But not the team itself.
324 TeamParticipation.person != self.id)
325 columns = [Person]
326+ decorators = []
327 if need_karma:
328 # New people have no karmatotalcache rows.
329 origin.append(
330@@ -1636,7 +1699,7 @@
331 Archive.owner == Person.id, Archive),
332 Archive.purpose == ArchivePurpose.PPA)))
333 # checking validity requires having a preferred email.
334- if need_preferred_email or need_validity:
335+ if need_preferred_email and not need_validity:
336 # Teams don't have email, so a left join
337 origin.append(
338 LeftJoin(EmailAddress, EmailAddress.person == Person.id))
339@@ -1645,14 +1708,10 @@
340 Or(EmailAddress.status == None,
341 EmailAddress.status == EmailAddressStatus.PREFERRED))
342 if need_validity:
343- # May find teams (teams are not valid people)
344- origin.append(
345- LeftJoin(Account, Person.account == Account.id))
346- columns.append(Account)
347- conditions = And(conditions,
348- Or(
349- Account.status == None,
350- Account.status == AccountStatus.ACTIVE))
351+ valid_stuff = Person._validity_queries()
352+ origin.extend(valid_stuff["joins"])
353+ columns.extend(valid_stuff["tables"])
354+ decorators.extend(valid_stuff["decorators"])
355 if len(columns) == 1:
356 columns = columns[0]
357 # Return a simple ResultSet
358@@ -1689,20 +1748,14 @@
359 index += 1
360 cache_property(result, '_archive_cached', archive)
361 #-- preferred email caching
362- if need_preferred_email:
363+ if need_preferred_email and not need_validity:
364 email = row[index]
365 index += 1
366 cache_property(result, '_preferredemail_cached', email)
367- #-- validity caching
368- if need_validity:
369- # valid if:
370- valid = (
371- # -- valid account found
372- row[index] is not None
373- # -- preferred email found
374- and result.preferredemail is not None)
375+ for decorator in decorators:
376+ column = row[index]
377 index += 1
378- cache_property(result, '_is_valid_person_cached', valid)
379+ decorator(result, column)
380 return result
381 return DecoratedResultSet(raw_result,
382 result_decorator=prepopulate_person)
383
384=== modified file 'lib/lp/registry/model/product.py'
385--- lib/lp/registry/model/product.py 2010-08-23 18:03:26 +0000
386+++ lib/lp/registry/model/product.py 2010-08-31 19:38:45 +0000
387@@ -1033,13 +1033,7 @@
388 # defaults for informationalness: we don't have to do anything
389 # because the default if nothing is said is ANY
390
391- # sort by priority descending, by default
392- if sort is None or sort == SpecificationSort.PRIORITY:
393- order = (
394- ['-priority', 'Specification.definition_status',
395- 'Specification.name'])
396- elif sort == SpecificationSort.DATE:
397- order = ['-Specification.datecreated', 'Specification.id']
398+ order = self._specification_sort(sort)
399
400 # figure out what set of specifications we are interested in. for
401 # products, we need to be able to filter on the basis of:
402@@ -1082,9 +1076,15 @@
403 query += ' AND Specification.fti @@ ftq(%s) ' % quote(
404 constraint)
405
406- results = Specification.select(query, orderBy=order, limit=quantity)
407 if prejoin_people:
408- results = results.prejoin(['assignee', 'approver', 'drafter'])
409+ results = self._preload_specifications_people(query)
410+ else:
411+ results = Store.of(self).find(
412+ Specification,
413+ SQL(query))
414+ results.order_by(order)
415+ if quantity is not None:
416+ results = results[:quantity]
417 return results
418
419 def getSpecification(self, name):