Merge lp:~lifeless/launchpad/bug-618367 into lp:launchpad
- bug-618367
- Merge into devel
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 |
Related bugs: |
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.
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): |
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' blueprints/ browser/ tests/test_ views.py 2010-08-20 20:31:18 +0000 blueprints/ browser/ tests/test_ views.py 2010-08-31 07:46:51 +0000 launchpad. testing. systemdocs import ( uite, launchpad. webapp import canonical_url nalLayer dirname( os.path. realpath( __file_ _)) tory, sampledata import ADMIN_EMAIL _webservice import QueryCollector
> --- lib/lp/
> +++ lib/lp/
> @@ -9,19 +9,92 @@
> import os
> import unittest
>
> +from storm.store import Store
> +from testtools.matchers import LessThan
> +
> from canonical.
> LayeredDocFileS
> setUp,
> tearDown,
> )
> +from canonical.
> from canonical.testing import DatabaseFunctio
> -
> -
> -here = os.path.
> -
> +from lp.testing import (
> + login,
> + logout,
> + TestCaseWithFac
> + )
> +from lp.testing.matchers import HasQueryCount
> +from lp.testing.
> +from lp.testing.
Should this be exported from lp.testing. __init_ _.py instead of importing from
_webservice?
> + (TestCaseWithFa ctory): nalLayer and_render( self, browser, dbobj, url): counts_ scaling_ with_unique_ people( self,
> +
> +class TestAssignments
> +
> + layer = DatabaseFunctio
> +
> + def invalidate_
> + # Ensure caches have been flushed.
> + store = Store.of(dbobj)
> + store.flush()
> + store.invalidate()
> + browser.open(url)
> +
> + def check_query_
> + 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. makeSpecificati on( target} )) register( ) (collector. unregister) makePerson( password= "test") wser(user= viewer) url(target) + "/+assignments" launchpad. webapp. serssion: _get_secret. _and_render( browser, target, url) (collector, HasQueryCount( LessThan( query_baseline) ))
> + **{targettype:
> + collector = QueryCollector()
> + collector.
> + self.addCleanup
> + viewer = self.factory.
> + browser = self.getUserBro
> + url = canonical_
> + # Seed the cookie cache and any other cross-request state we may gain
> + # in future. See canonical.
> + browser.open(url)
> + self.invalidate
> + # Set a baseline
> + self.assertThat
> + no_assignees_count = collector.count
> + # Assign many unique people, which shouldn't change the page queries.
> + # Due to storm bug 619017 additional...