Merge lp:~edwin-grubbs/launchpad/bug-638924-milestone-page-eager-loading into lp:launchpad

Proposed by Edwin Grubbs on 2010-12-03
Status: Merged
Approved by: Edwin Grubbs on 2010-12-09
Approved revision: no longer in the source branch.
Merged at revision: 12038
Proposed branch: lp:~edwin-grubbs/launchpad/bug-638924-milestone-page-eager-loading
Merge into: lp:launchpad
Diff against target: 838 lines (+483/-129)
7 files modified
lib/lp/bugs/interfaces/bugtask.py (+9/-1)
lib/lp/bugs/model/bugtask.py (+74/-8)
lib/lp/registry/browser/milestone.py (+5/-19)
lib/lp/registry/browser/tests/test_milestone.py (+212/-4)
lib/lp/registry/interfaces/person.py (+18/-0)
lib/lp/registry/model/person.py (+137/-95)
lib/lp/registry/tests/test_person.py (+28/-2)
To merge this branch: bzr merge lp:~edwin-grubbs/launchpad/bug-638924-milestone-page-eager-loading
Reviewer Review Type Date Requested Status
Abel Deuring (community) code 2010-12-03 Approve on 2010-12-03
Review via email: mp+42586@code.launchpad.net

Commit Message

[r=adeuring][ui=none][bug=638924] Fixed timeout on ubuntu milestone +index page.

Description of the Change

Summary
-------

This branch fixes timeouts viewing a distro's milestone +index page with
a ton of bugs. The number of queries will be constant for a milestone
page on a distro or a project. The milestone page for a project group
will make two additional queries for each of its project that has a
milestone with a matching name.

Implementation details
----------------------

Added search functionality to BugTaskSet.search() to eliminate the need
to call getConjoinedMaster() on each bug task found. Also, precached all
the bug assignees and their validity. (Validity is used by tales to
decide whether the person icon should be grayed out.)
    lib/lp/bugs/interfaces/bugtask.py
    lib/lp/bugs/model/bugtask.py
    lib/lp/registry/browser/milestone.py
    lib/lp/registry/browser/tests/test_milestone.py

Moved code for precaching various person attributes from the Person
class into the PersonSet class so that it can be more easily reused.
    lib/lp/registry/interfaces/person.py
    lib/lp/registry/model/person.py

Tests
-----

./bin/test -vv -t 'test_milestone|milestone-views.txt'

Demo and Q/A
------------

* Open https://qastaging.launchpad.net/ubuntu/+milestone/ubuntu-9.10/
    * It should not timeout, and the query count should be under 40.
* Open https://qastaging.launchpad.net/launchpad-project/+milestone/10.12
    * It should not timeout, and the query count should be under 80.
* Open https://qastaging.launchpad.net/launchpad-registry/+milestone/10.12
    * It should not timeout, and the query count should be under 40.

Lint
----

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/bugs/interfaces/bugtask.py
  lib/lp/bugs/model/bugtask.py
  lib/lp/registry/browser/milestone.py
  lib/lp/registry/browser/tests/test_milestone.py
  lib/lp/registry/interfaces/person.py
  lib/lp/registry/model/person.py

./lib/lp/registry/interfaces/person.py
     493: E302 expected 2 blank lines, found 1
    2095: E302 expected 2 blank lines, found 3

To post a comment you must log in.
Abel Deuring (adeuring) wrote :

Hi Edwin,

this is a very nice branch! I just have a few remarks, nothing serious.

> + def getPrecachedNonConjoinedBugTasks(self, user, milestone):
> + """See `IBugTaskSet`."""
> + params = BugTaskSearchParams(
> + user, milestone=milestone,
> + orderby=['status', '-importance', 'id'],
> + omit_dupes=True, exclude_conjoined_tasks=True)
> + non_conjoined_slaves = self.search(params)
> +
> + def cache_people(rows):
> + assignee_ids = set(
> + bug_task.assigneeID for bug_task in rows)
> + assignees = getUtility(IPersonSet).getPrecachedPersonsFromIDs(
> + assignee_ids, need_validity=True)
> + # Execute query to load storm cache.
> + list(assignees)
> +
> + return DecoratedResultSet(
> + non_conjoined_slaves, pre_iter_hook=cache_people)
> +

Preloading the Person records this way is fine, but did you consider
to use the optional BugTaskSet.search() parameter prejoins? Like so:

    self.search(
        params, prejoins=[(Person, LeftJoin(BugTask.assignee==Person.id)])

Seeing that you implemented another prejoin mechanism in PersonSet,
I get the impression that we should perhaps "normalize" the approaches
to preload records. But that's something for future branches.

> +class TestProjectGroupMilestoneIndexQueryCount(TestCaseWithFactory):
> +
> + layer = DatabaseFunctionalLayer

The tests for the different milestone targets a quite similar. Did
you consider to use a comon base class containing the test methods
and to setup the milestone, milestone target etc in derived classes?

review: Approve (code)
Edwin Grubbs (edwin-grubbs) wrote :

> Hi Edwin,
>
> this is a very nice branch! I just have a few remarks, nothing serious.
>
>
>
> > + def getPrecachedNonConjoinedBugTasks(self, user, milestone):
> > + """See `IBugTaskSet`."""
> > + params = BugTaskSearchParams(
> > + user, milestone=milestone,
> > + orderby=['status', '-importance', 'id'],
> > + omit_dupes=True, exclude_conjoined_tasks=True)
> > + non_conjoined_slaves = self.search(params)
> > +
> > + def cache_people(rows):
> > + assignee_ids = set(
> > + bug_task.assigneeID for bug_task in rows)
> > + assignees = getUtility(IPersonSet).getPrecachedPersonsFromIDs(
> > + assignee_ids, need_validity=True)
> > + # Execute query to load storm cache.
> > + list(assignees)
> > +
> > + return DecoratedResultSet(
> > + non_conjoined_slaves, pre_iter_hook=cache_people)
> > +
>
> Preloading the Person records this way is fine, but did you consider
> to use the optional BugTaskSet.search() parameter prejoins? Like so:
>
> self.search(
> params, prejoins=[(Person, LeftJoin(BugTask.assignee==Person.id)])

I thought about it, but I think there are some advantages to not loading a bunch of duplicate data since many of the bugs will share the same assignee. I also needed the Person.is_valid_person attribute to be precached, and this can't be done with a simple prejoin since that attribute is not handled by storm.

> Seeing that you implemented another prejoin mechanism in PersonSet,
> I get the impression that we should perhaps "normalize" the approaches
> to preload records. But that's something for future branches.

I didn't actually implement that. The code was already there for precaching info in Team.allmembers. I just moved some of the code to PersonSet where it could be reused more easily.

> > +class TestProjectGroupMilestoneIndexQueryCount(TestCaseWithFactory):
> > +
> > + layer = DatabaseFunctionalLayer
>
> The tests for the different milestone targets a quite similar. Did
> you consider to use a comon base class containing the test methods
> and to setup the milestone, milestone target etc in derived classes?

I have refactored some of the redundancy out, but I left the test methods in each class, since the number of queries may differ for each milestone target type.

I also added a test for PersonSet.getPrecachedPersonsFromIDs().

Edwin Grubbs (edwin-grubbs) wrote :
Download full text (13.5 KiB)

Here is the incremental diff:

=== modified file 'lib/lp/registry/browser/tests/test_milestone.py'
--- lib/lp/registry/browser/tests/test_milestone.py 2010-12-03 03:03:14 +0000
+++ lib/lp/registry/browser/tests/test_milestone.py 2010-12-03 16:27:46 +0000
@@ -169,7 +169,39 @@
         self.assertEqual(0, product.development_focus.all_bugtasks.count())

-class TestProjectMilestoneIndexQueryCount(TestCaseWithFactory):
+class TestQueryCountBase(TestCaseWithFactory):
+
+ def assert_bugtasks_query_count(self, milestone, bugtask_count,
+ query_limit):
+ # Assert that the number of queries run by view.bugtasks is low.
+ self.add_bug(bugtask_count)
+ login_person(self.owner)
+ view = create_initialized_view(milestone, '+index')
+ # Eliminate permission check for the admin team from the
+ # recorded queries by loading it now. If the test ever breaks,
+ # the person fixing it won't waste time trying to track this
+ # query down.
+ getUtility(ILaunchpadCelebrities).admin
+ with StormStatementRecorder() as recorder:
+ bugtasks = list(view.bugtasks)
+ self.assertThat(recorder, HasQueryCount(LessThan(query_limit)))
+ self.assertEqual(bugtask_count, len(bugtasks))
+
+ def assert_milestone_page_query_count(self, milestone, query_limit):
+ collector = QueryCollector()
+ collector.register()
+ try:
+ milestone_url = canonical_url(milestone)
+ browser = self.getUserBrowser(user=self.owner)
+ browser.open(milestone_url)
+ self.assertThat(collector, HasQueryCount(LessThan(query_limit)))
+ finally:
+ # Unregister now in case this method is called multiple
+ # times in a single test.
+ collector.unregister()
+
+
+class TestProjectMilestoneIndexQueryCount(TestQueryCountBase):

     layer = DatabaseFunctionalLayer

@@ -182,8 +214,8 @@
             storm_cache_size: 1000
             '''))
         self.addCleanup(config.pop, 'storm-cache')
- owner = self.factory.makePerson(name='product-owner')
- self.product = self.factory.makeProduct(owner=owner)
+ self.owner = self.factory.makePerson(name='product-owner')
+ self.product = self.factory.makeProduct(owner=self.owner)
         login_person(self.product.owner)
         self.milestone = self.factory.makeMilestone(
             productseries=self.product.development_focus)
@@ -206,36 +238,19 @@
         # 3. Load links to specifications.
         # 4. Load links to branches.
         bugtask_count = 10
- self.add_bug(bugtask_count)
- login_person(self.product.owner)
- view = create_initialized_view(self.milestone, '+index')
- # Eliminate permission check for the admin team from the
- # recorded queries by loading it now. If the test ever breaks,
- # the person fixing it won't waste time trying to track this
- # query down.
- getUtility(ILaunchpadCelebrities).admin
- with StormStatementRecorder() as recorder:
- bugtasks = list(view.bugtasks)
- self.assertThat(recorder, Has...

Abel Deuring (adeuring) wrote :

Hi Edwin,

thanks for the additional changes, r=me again. Just one nitpick:

> @@ -339,56 +354,29 @@
>
> def test_bugtasks_queries(self):
> # The view.bugtasks attribute will make four queries:

s/four/five/

review: Approve (code)
Robert Collins (lifeless) wrote :

I have one small comment... you've moved from Store.of(object) to
IStore - this is a problem because it will miss the storm cache if
some page/request determines it needs IMaster explicitly.

You need to pass the store into the precache help you've put on PersonSet.

-Rob

Edwin Grubbs (edwin-grubbs) wrote :

I have added a "store" parameter to the _getPrecachedPersons() so that Person._all_members() can specify it.

Robert Collins (lifeless) wrote :

Thanks!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/bugs/interfaces/bugtask.py'
2--- lib/lp/bugs/interfaces/bugtask.py 2010-12-01 11:26:57 +0000
3+++ lib/lp/bugs/interfaces/bugtask.py 2010-12-09 15:28:31 +0000
4@@ -1145,7 +1145,8 @@
5 hardware_owner_is_subscribed_to_bug=False,
6 hardware_is_linked_to_bug=False,
7 linked_branches=None, structural_subscriber=None,
8- modified_since=None, created_since=None):
9+ modified_since=None, created_since=None,
10+ exclude_conjoined_tasks=False):
11
12 self.bug = bug
13 self.searchtext = searchtext
14@@ -1191,6 +1192,7 @@
15 self.structural_subscriber = structural_subscriber
16 self.modified_since = modified_since
17 self.created_since = created_since
18+ self.exclude_conjoined_tasks = exclude_conjoined_tasks
19
20 def setProduct(self, product):
21 """Set the upstream context on which to filter the search."""
22@@ -1530,6 +1532,12 @@
23 This takes into account bug subscription filters.
24 """
25
26+ def getPrecachedNonConjoinedBugTasks(user, milestone):
27+ """List of non-conjoined bugtasks targeted to the milestone.
28+
29+ The assignee and the assignee's validity are precached.
30+ """
31+
32
33 def valid_remote_bug_url(value):
34 """Verify that the URL is to a bug to a known bug tracker."""
35
36=== modified file 'lib/lp/bugs/model/bugtask.py'
37--- lib/lp/bugs/model/bugtask.py 2010-12-01 11:26:57 +0000
38+++ lib/lp/bugs/model/bugtask.py 2010-12-09 15:28:31 +0000
39@@ -43,14 +43,11 @@
40 Select,
41 SQL,
42 )
43+from storm.info import ClassAlias
44 from storm.store import (
45 EmptyResultSet,
46 Store,
47 )
48-from storm.zope.interfaces import (
49- IResultSet,
50- ISQLObjectResultSet,
51- )
52 from zope.component import getUtility
53 from zope.interface import (
54 alsoProvides,
55@@ -142,6 +139,7 @@
56 from lp.registry.interfaces.milestone import IProjectGroupMilestone
57 from lp.registry.interfaces.person import (
58 IPerson,
59+ IPersonSet,
60 validate_person,
61 validate_public_person,
62 )
63@@ -1490,16 +1488,23 @@
64 from lp.bugs.model.bug import Bug
65 from lp.bugs.model.bugbranch import BugBranch
66
67- bug_ids = list(set(bugtask.bugID for bugtask in bugtasks))
68+ bug_ids = set(bugtask.bugID for bugtask in bugtasks)
69 bug_ids_with_specifications = set(IStore(SpecificationBug).find(
70 SpecificationBug.bugID,
71 SpecificationBug.bugID.is_in(bug_ids)))
72 bug_ids_with_branches = set(IStore(BugBranch).find(
73 BugBranch.bugID, BugBranch.bugID.is_in(bug_ids)))
74
75- # Cache all bugs at once to avoid one query per bugtask. We
76- # could rely on the Storm cache, but this is explicit.
77- bugs = dict(IStore(Bug).find((Bug.id, Bug), Bug.id.is_in(bug_ids)))
78+ # Check if the bugs are cached. If not, cache all uncached bugs
79+ # at once to avoid one query per bugtask. We could rely on the
80+ # Storm cache, but this is explicit.
81+ bugs = dict(
82+ (bug.id, bug)
83+ for bug in IStore(Bug).find(Bug, Bug.id.is_in(bug_ids)).cached())
84+ uncached_ids = bug_ids.difference(bug_id for bug_id in bugs)
85+ if len(uncached_ids) > 0:
86+ bugs.update(dict(IStore(Bug).find((Bug.id, Bug),
87+ Bug.id.is_in(uncached_ids))))
88
89 badge_properties = {}
90 for bugtask in bugtasks:
91@@ -1665,6 +1670,48 @@
92 where_cond = search_value_to_where_condition(params.milestone)
93 extra_clauses.append("BugTask.milestone %s" % where_cond)
94
95+ if params.exclude_conjoined_tasks:
96+ # Perform a LEFT JOIN to the conjoined master bugtask.
97+ # If the conjoined master is not null, it gets filtered
98+ # out.
99+ ConjoinedMaster = ClassAlias(BugTask, 'ConjoinedMaster')
100+ extra_clauses.append("ConjoinedMaster.id IS NULL")
101+ if params.milestone.distribution is not None:
102+ current_series = (
103+ params.milestone.distribution.currentseries)
104+ join = LeftJoin(
105+ ConjoinedMaster,
106+ And(ConjoinedMaster.bug == BugTask.bugID,
107+ ConjoinedMaster.distroseries == current_series.id,
108+ ))
109+ join_tables.append((ConjoinedMaster, join))
110+ else:
111+ # Prevent import loop.
112+ from lp.registry.model.product import Product
113+ if IProjectGroupMilestone.providedBy(params.milestone):
114+ dev_focus_ids = list(
115+ IStore(Product).find(
116+ Product.development_focusID,
117+ Product.project == params.milestone.target))
118+ elif params.milestone.product is not None:
119+ dev_focus_ids = [
120+ params.milestone.product.development_focusID]
121+ else:
122+ raise AssertionError(
123+ "A milestone must always have either a project, "
124+ "project group, or distribution")
125+ join = LeftJoin(
126+ ConjoinedMaster,
127+ And(ConjoinedMaster.bug == BugTask.bugID,
128+ ConjoinedMaster.productseriesID.is_in(
129+ dev_focus_ids)))
130+ join_tables.append((ConjoinedMaster, join))
131+ elif params.exclude_conjoined_tasks:
132+ raise ValueError(
133+ "BugTaskSearchParam.exclude_conjoined cannot be True if "
134+ "BugTaskSearchParam.milestone is not set")
135+
136+
137 if params.project:
138 # Prevent circular import problems.
139 from lp.registry.model.product import Product
140@@ -2309,6 +2356,25 @@
141 """See `IBugTaskSet`."""
142 return self._search(BugTask.bugID, [], params).result_set
143
144+ def getPrecachedNonConjoinedBugTasks(self, user, milestone):
145+ """See `IBugTaskSet`."""
146+ params = BugTaskSearchParams(
147+ user, milestone=milestone,
148+ orderby=['status', '-importance', 'id'],
149+ omit_dupes=True, exclude_conjoined_tasks=True)
150+ non_conjoined_slaves = self.search(params)
151+
152+ def cache_people(rows):
153+ assignee_ids = set(
154+ bug_task.assigneeID for bug_task in rows)
155+ assignees = getUtility(IPersonSet).getPrecachedPersonsFromIDs(
156+ assignee_ids, need_validity=True)
157+ # Execute query to load storm cache.
158+ list(assignees)
159+
160+ return DecoratedResultSet(
161+ non_conjoined_slaves, pre_iter_hook=cache_people)
162+
163 def createTask(self, bug, owner, product=None, productseries=None,
164 distribution=None, distroseries=None,
165 sourcepackagename=None,
166
167=== modified file 'lib/lp/registry/browser/milestone.py'
168--- lib/lp/registry/browser/milestone.py 2010-11-23 23:22:27 +0000
169+++ lib/lp/registry/browser/milestone.py 2010-12-09 15:28:31 +0000
170@@ -41,7 +41,6 @@
171 precache_permission_for_objects,
172 )
173 from canonical.launchpad.webapp.breadcrumb import Breadcrumb
174-from canonical.launchpad.webapp.interfaces import ILaunchBag
175 from canonical.launchpad.webapp.menu import (
176 ApplicationMenu,
177 ContextMenu,
178@@ -57,7 +56,6 @@
179 )
180 from lp.bugs.browser.bugtask import BugTaskListingItem
181 from lp.bugs.interfaces.bugtask import (
182- BugTaskSearchParams,
183 IBugTaskSet,
184 )
185 from lp.registry.browser import (
186@@ -249,23 +247,11 @@
187 @cachedproperty
188 def _bugtasks(self):
189 """The list of non-conjoined bugtasks targeted to this milestone."""
190- user = getUtility(ILaunchBag).user
191- params = BugTaskSearchParams(user, milestone=self.context,
192- orderby=['status', '-importance', 'id'],
193- omit_dupes=True)
194- tasks = getUtility(IBugTaskSet).search(params)
195- # We could replace all the code below with a simple
196- # >>> [task for task in tasks if task.conjoined_master is None]
197- # But that'd cause one extra hit to the DB for every bugtask returned
198- # by the search above, so we do a single query to get all of a task's
199- # siblings here and use that to find whether or not a given bugtask
200- # has a conjoined master.
201- bugs_and_tasks = getUtility(IBugTaskSet).getBugTasks(
202- [task.bug.id for task in tasks])
203- non_conjoined_slaves = []
204- for task in tasks:
205- if task.getConjoinedMaster(bugs_and_tasks[task.bug]) is None:
206- non_conjoined_slaves.append(task)
207+ # Put the results in a list so that iterating over it multiple
208+ # times in this method does not make multiple queries.
209+ non_conjoined_slaves = list(
210+ getUtility(IBugTaskSet).getPrecachedNonConjoinedBugTasks(
211+ self.user, self.context))
212 # Checking bug permissions is expensive. We know from the query that
213 # the user has at least launchpad.View on the bugtasks and their bugs.
214 precache_permission_for_objects(
215
216=== modified file 'lib/lp/registry/browser/tests/test_milestone.py'
217--- lib/lp/registry/browser/tests/test_milestone.py 2010-10-26 15:47:24 +0000
218+++ lib/lp/registry/browser/tests/test_milestone.py 2010-12-09 15:28:31 +0000
219@@ -5,9 +5,13 @@
220
221 __metaclass__ = type
222
223+from textwrap import dedent
224+
225 from testtools.matchers import LessThan
226 from zope.component import getUtility
227
228+from canonical.config import config
229+from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
230 from canonical.launchpad.webapp import canonical_url
231 from canonical.testing.layers import DatabaseFunctionalLayer
232 from lp.bugs.interfaces.bugtask import IBugTaskSet
233@@ -15,14 +19,16 @@
234 ANONYMOUS,
235 login,
236 login_person,
237+ login_team,
238 logout,
239 person_logged_in,
240+ StormStatementRecorder,
241 TestCaseWithFactory,
242 )
243+from lp.testing._webservice import QueryCollector
244 from lp.testing.matchers import HasQueryCount
245 from lp.testing.memcache import MemcacheTestCase
246 from lp.testing.views import create_initialized_view
247-from lp.testing._webservice import QueryCollector
248
249
250 class TestMilestoneViews(TestCaseWithFactory):
251@@ -163,10 +169,89 @@
252 self.assertEqual(0, product.development_focus.all_bugtasks.count())
253
254
255-class TestMilestoneIndex(TestCaseWithFactory):
256+class TestQueryCountBase(TestCaseWithFactory):
257+
258+ def assert_bugtasks_query_count(self, milestone, bugtask_count,
259+ query_limit):
260+ # Assert that the number of queries run by view.bugtasks is low.
261+ self.add_bug(bugtask_count)
262+ login_person(self.owner)
263+ view = create_initialized_view(milestone, '+index')
264+ # Eliminate permission check for the admin team from the
265+ # recorded queries by loading it now. If the test ever breaks,
266+ # the person fixing it won't waste time trying to track this
267+ # query down.
268+ getUtility(ILaunchpadCelebrities).admin
269+ with StormStatementRecorder() as recorder:
270+ bugtasks = list(view.bugtasks)
271+ self.assertThat(recorder, HasQueryCount(LessThan(query_limit)))
272+ self.assertEqual(bugtask_count, len(bugtasks))
273+
274+ def assert_milestone_page_query_count(self, milestone, query_limit):
275+ collector = QueryCollector()
276+ collector.register()
277+ try:
278+ milestone_url = canonical_url(milestone)
279+ browser = self.getUserBrowser(user=self.owner)
280+ browser.open(milestone_url)
281+ self.assertThat(collector, HasQueryCount(LessThan(query_limit)))
282+ finally:
283+ # Unregister now in case this method is called multiple
284+ # times in a single test.
285+ collector.unregister()
286+
287+
288+class TestProjectMilestoneIndexQueryCount(TestQueryCountBase):
289
290 layer = DatabaseFunctionalLayer
291
292+ def setUp(self):
293+ super(TestProjectMilestoneIndexQueryCount, self).setUp()
294+ # Increase cache size so that the query counts aren't affected
295+ # by objects being removed from the cache early.
296+ config.push('storm-cache', dedent('''
297+ [launchpad]
298+ storm_cache_size: 1000
299+ '''))
300+ self.addCleanup(config.pop, 'storm-cache')
301+ self.owner = self.factory.makePerson(name='product-owner')
302+ self.product = self.factory.makeProduct(owner=self.owner)
303+ login_person(self.product.owner)
304+ self.milestone = self.factory.makeMilestone(
305+ productseries=self.product.development_focus)
306+
307+ def add_bug(self, count):
308+ login_person(self.product.owner)
309+ for i in range(count):
310+ bug = self.factory.makeBug(product=self.product)
311+ bug.bugtasks[0].transitionToMilestone(
312+ self.milestone, self.product.owner)
313+ # This is necessary to test precaching of assignees.
314+ bug.bugtasks[0].transitionToAssignee(
315+ self.factory.makePerson())
316+ logout()
317+
318+ def test_bugtasks_queries(self):
319+ # The view.bugtasks attribute will make four queries:
320+ # 1. Load bugtasks and bugs.
321+ # 2. Load assignees (Person, Account, and EmailAddress).
322+ # 3. Load links to specifications.
323+ # 4. Load links to branches.
324+ bugtask_count = 10
325+ self.assert_bugtasks_query_count(
326+ self.milestone, bugtask_count, query_limit=5)
327+
328+ def test_milestone_eager_loading(self):
329+ # Verify that the number of queries does not increase with more
330+ # bugs with different assignees.
331+ query_limit = 34
332+ self.add_bug(3)
333+ self.assert_milestone_page_query_count(
334+ self.milestone, query_limit=query_limit)
335+ self.add_bug(10)
336+ self.assert_milestone_page_query_count(
337+ self.milestone, query_limit=query_limit)
338+
339 def test_more_private_bugs_query_count_is_constant(self):
340 # This test tests that as we add more private bugs to a milestone
341 # index page, the number of queries issued by the page does not
342@@ -175,6 +260,7 @@
343 # is very large already, if the test fails due to such a change,
344 # please cut some more of the existing fat out of it rather than
345 # increasing the cap.
346+ page_query_limit = 34
347 product = self.factory.makeProduct()
348 login_person(product.owner)
349 milestone = self.factory.makeMilestone(
350@@ -199,8 +285,6 @@
351 collector = QueryCollector()
352 collector.register()
353 self.addCleanup(collector.unregister)
354- # This page is rather high in queries : 46 as a baseline. 20100817
355- page_query_limit = 46
356 browser.open(milestone_url)
357 # Check that the test found the bug
358 self.assertTrue(bug1_url in browser.contents)
359@@ -228,3 +312,127 @@
360 self.assertEqual(with_1_private_bug, with_3_private_bugs,
361 "different query count: \n%s\n******************\n%s\n" % (
362 '\n'.join(with_1_queries), '\n'.join(with_3_queries)))
363+
364+
365+class TestProjectGroupMilestoneIndexQueryCount(TestQueryCountBase):
366+
367+ layer = DatabaseFunctionalLayer
368+
369+ def setUp(self):
370+ super(TestProjectGroupMilestoneIndexQueryCount, self).setUp()
371+ # Increase cache size so that the query counts aren't affected
372+ # by objects being removed from the cache early.
373+ config.push('storm-cache', dedent('''
374+ [launchpad]
375+ storm_cache_size: 1000
376+ '''))
377+ self.addCleanup(config.pop, 'storm-cache')
378+ self.owner = self.factory.makePerson(name='product-owner')
379+ self.project_group = self.factory.makeProject(owner=self.owner)
380+ login_person(self.owner)
381+ self.milestone_name = 'foo'
382+ # A ProjectGroup milestone doesn't exist unless one of its
383+ # Projects has a milestone of that name.
384+ product = self.factory.makeProduct(
385+ owner=self.owner, project=self.project_group)
386+ self.product_milestone = self.factory.makeMilestone(
387+ productseries=product.development_focus,
388+ name=self.milestone_name)
389+ self.milestone = self.project_group.getMilestone(
390+ self.milestone_name)
391+
392+ def add_bug(self, count):
393+ login_person(self.owner)
394+ for i in range(count):
395+ bug = self.factory.makeBug(product=self.product_milestone.product)
396+ bug.bugtasks[0].transitionToMilestone(
397+ self.product_milestone, self.owner)
398+ # This is necessary to test precaching of assignees.
399+ bug.bugtasks[0].transitionToAssignee(
400+ self.factory.makePerson())
401+ logout()
402+
403+ def test_bugtasks_queries(self):
404+ # The view.bugtasks attribute will make five queries:
405+ # 1. For each project in the group load all the dev focus series ids.
406+ # 2. Load bugtasks and bugs.
407+ # 3. Load assignees (Person, Account, and EmailAddress).
408+ # 4. Load links to specifications.
409+ # 5. Load links to branches.
410+ bugtask_count = 10
411+ self.assert_bugtasks_query_count(
412+ self.milestone, bugtask_count, query_limit=6)
413+
414+ def test_milestone_eager_loading(self):
415+ # Verify that the number of queries does not increase with more
416+ # bugs with different assignees as long as the number of
417+ # projects doesn't increase.
418+ query_limit = 37
419+ self.add_bug(1)
420+ self.assert_milestone_page_query_count(
421+ self.milestone, query_limit=query_limit)
422+ self.add_bug(10)
423+ self.assert_milestone_page_query_count(
424+ self.milestone, query_limit=query_limit)
425+
426+
427+class TestDistributionMilestoneIndexQueryCount(TestQueryCountBase):
428+
429+ layer = DatabaseFunctionalLayer
430+
431+ def setUp(self):
432+ super(TestDistributionMilestoneIndexQueryCount, self).setUp()
433+ # Increase cache size so that the query counts aren't affected
434+ # by objects being removed from the cache early.
435+ config.push('storm-cache', dedent('''
436+ [launchpad]
437+ storm_cache_size: 1000
438+ '''))
439+ self.addCleanup(config.pop, 'storm-cache')
440+ self.ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
441+ self.owner = self.factory.makePerson(name='test-owner')
442+ login_team(self.ubuntu.owner)
443+ self.ubuntu.owner = self.owner
444+ self.sourcepackagename = self.factory.makeSourcePackageName(
445+ 'foo-package')
446+ login_person(self.owner)
447+ self.milestone = self.factory.makeMilestone(
448+ distribution=self.ubuntu)
449+
450+ def add_bug(self, count):
451+ login_person(self.owner)
452+ for i in range(count):
453+ bug = self.factory.makeBug(distribution=self.ubuntu)
454+ distrosourcepackage = self.factory.makeDistributionSourcePackage(
455+ distribution=self.ubuntu)
456+ bug.bugtasks[0].transitionToTarget(distrosourcepackage)
457+ bug.bugtasks[0].transitionToMilestone(
458+ self.milestone, self.owner)
459+ # This is necessary to test precaching of assignees.
460+ bug.bugtasks[0].transitionToAssignee(
461+ self.factory.makePerson())
462+ logout()
463+
464+ def test_bugtasks_queries(self):
465+ # The view.bugtasks attribute will make seven queries:
466+ # 1. Load ubuntu.currentseries.
467+ # 2. Check if the user is in the admin team.
468+ # 3. Check if the user is in the owner of the admin team.
469+ # 4. Load bugtasks and bugs.
470+ # 5. Load assignees (Person, Account, and EmailAddress).
471+ # 6. Load links to specifications.
472+ # 7. Load links to branches.
473+ bugtask_count = 10
474+ self.assert_bugtasks_query_count(
475+ self.milestone, bugtask_count, query_limit=8)
476+
477+ def test_milestone_eager_loading(self):
478+ # Verify that the number of queries does not increase with more
479+ # bugs with different assignees.
480+ query_limit = 32
481+ self.add_bug(3)
482+ self.assert_milestone_page_query_count(
483+ self.milestone, query_limit=query_limit)
484+ self.add_bug(10)
485+ self.assert_milestone_page_query_count(
486+ self.milestone, query_limit=query_limit)
487
488=== modified file 'lib/lp/registry/interfaces/person.py'
489--- lib/lp/registry/interfaces/person.py 2010-11-22 23:08:11 +0000
490+++ lib/lp/registry/interfaces/person.py 2010-12-09 15:28:31 +0000
491@@ -2073,6 +2073,24 @@
492 def cacheBrandingForPeople(people):
493 """Prefetch Librarian aliases and content for personal images."""
494
495+ def getPrecachedPersonsFromIDs(
496+ person_ids, need_karma=False, need_ubuntu_coc=False,
497+ need_location=False, need_archive=False,
498+ need_preferred_email=False, need_validity=False):
499+ """Lookup person objects from ids with optional precaching.
500+
501+ :param person_ids: List of person ids.
502+ :param need_karma: The karma attribute will be cached.
503+ :param need_ubuntu_coc: The is_ubuntu_coc_signer attribute will be
504+ cached.
505+ :param need_location: The location attribute will be cached.
506+ :param need_archive: The archive attribute will be cached.
507+ :param need_preferred_email: The preferred email attribute will be
508+ cached.
509+ :param need_validity: The is_valid attribute will be cached.
510+ """
511+
512+
513
514 class IRequestPeopleMerge(Interface):
515 """This schema is used only because we want a very specific vocabulary."""
516
517=== modified file 'lib/lp/registry/model/person.py'
518--- lib/lp/registry/model/person.py 2010-12-03 16:33:03 +0000
519+++ lib/lp/registry/model/person.py 2010-12-09 15:28:31 +0000
520@@ -1473,7 +1473,6 @@
521 # Since we've updated the database behind Storm's back,
522 # flush its caches.
523 store.invalidate()
524-
525
526 # Remove all indirect TeamParticipation entries resulting from this
527 # team. If this were just a select, it would be a complicated but
528@@ -1669,7 +1668,6 @@
529 # The difference between the two is that
530 # getMembersWithPreferredEmails includes self, which is arguably
531 # wrong, but perhaps deliberate.
532- store = Store.of(self)
533 origin = [
534 Person,
535 Join(TeamParticipation, TeamParticipation.person == Person.id),
536@@ -1679,99 +1677,12 @@
537 TeamParticipation.team == self.id,
538 # But not the team itself.
539 TeamParticipation.person != self.id)
540- columns = [Person]
541- decorators = []
542- if need_karma:
543- # New people have no karmatotalcache rows.
544- origin.append(
545- LeftJoin(KarmaTotalCache,
546- KarmaTotalCache.person == Person.id))
547- columns.append(KarmaTotalCache)
548- if need_ubuntu_coc:
549- columns.append(Alias(Exists(Select(SignedCodeOfConduct,
550- And(Person._is_ubuntu_coc_signer_condition(),
551- SignedCodeOfConduct.ownerID == Person.id))),
552- name='is_ubuntu_coc_signer'))
553- if need_location:
554- # New people have no location rows
555- origin.append(
556- LeftJoin(PersonLocation,
557- PersonLocation.person == Person.id))
558- columns.append(PersonLocation)
559- if need_archive:
560- # Not everyone has PPA's
561- # It would be nice to cleanly expose the soyuz rules for this to
562- # avoid duplicating the relationships.
563- origin.append(
564- LeftJoin(Archive, Archive.owner == Person.id))
565- columns.append(Archive)
566- conditions = And(conditions,
567- Or(Archive.id == None, And(
568- Archive.id == Select(Min(Archive.id),
569- Archive.owner == Person.id, Archive),
570- Archive.purpose == ArchivePurpose.PPA)))
571- # checking validity requires having a preferred email.
572- if need_preferred_email and not need_validity:
573- # Teams don't have email, so a left join
574- origin.append(
575- LeftJoin(EmailAddress, EmailAddress.person == Person.id))
576- columns.append(EmailAddress)
577- conditions = And(conditions,
578- Or(EmailAddress.status == None,
579- EmailAddress.status == EmailAddressStatus.PREFERRED))
580- if need_validity:
581- valid_stuff = Person._validity_queries()
582- origin.extend(valid_stuff["joins"])
583- columns.extend(valid_stuff["tables"])
584- decorators.extend(valid_stuff["decorators"])
585- if len(columns) == 1:
586- columns = columns[0]
587- # Return a simple ResultSet
588- return store.using(*origin).find(columns, conditions)
589- # Adapt the result into a cached Person.
590- columns = tuple(columns)
591- raw_result = store.using(*origin).find(columns, conditions)
592-
593- def prepopulate_person(row):
594- result = row[0]
595- cache = get_property_cache(result)
596- index = 1
597- #-- karma caching
598- if need_karma:
599- karma = row[index]
600- index += 1
601- if karma is None:
602- karma_total = 0
603- else:
604- karma_total = karma.karma_total
605- cache.karma = karma_total
606- #-- ubuntu code of conduct signer status caching.
607- if need_ubuntu_coc:
608- signed = row[index]
609- index += 1
610- cache.is_ubuntu_coc_signer = signed
611- #-- location caching
612- if need_location:
613- location = row[index]
614- index += 1
615- cache.location = location
616- #-- archive caching
617- if need_archive:
618- archive = row[index]
619- index += 1
620- cache.archive = archive
621- #-- preferred email caching
622- if need_preferred_email and not need_validity:
623- email = row[index]
624- index += 1
625- cache.preferredemail = email
626- for decorator in decorators:
627- column = row[index]
628- index += 1
629- decorator(result, column)
630- return result
631- return DecoratedResultSet(raw_result,
632- result_decorator=prepopulate_person)
633+ return PersonSet()._getPrecachedPersons(
634+ origin, conditions, store=Store.of(self),
635+ need_karma=need_karma, need_ubuntu_coc=need_ubuntu_coc,
636+ need_location=need_location, need_archive=need_archive,
637+ need_preferred_email=need_preferred_email,
638+ need_validity=need_validity)
639
640 def _getMembersWithPreferredEmails(self):
641 """Helper method for public getMembersWithPreferredEmails.
642@@ -4126,6 +4037,137 @@
643 list(LibraryFileAlias.select("LibraryFileAlias.id IN %s"
644 % sqlvalues(aliases), prejoins=["content"]))
645
646+ def getPrecachedPersonsFromIDs(
647+ self, person_ids, need_karma=False, need_ubuntu_coc=False,
648+ need_location=False, need_archive=False,
649+ need_preferred_email=False, need_validity=False):
650+ """See `IPersonSet`."""
651+ origin = [Person]
652+ conditions = [
653+ Person.id.is_in(person_ids)]
654+ return self._getPrecachedPersons(
655+ origin, conditions,
656+ need_karma=need_karma, need_ubuntu_coc=need_ubuntu_coc,
657+ need_location=need_location, need_archive=need_archive,
658+ need_preferred_email=need_preferred_email,
659+ need_validity=need_validity)
660+
661+ def _getPrecachedPersons(
662+ self, origin, conditions, store=None,
663+ need_karma=False, need_ubuntu_coc=False,
664+ need_location=False, need_archive=False, need_preferred_email=False,
665+ need_validity=False):
666+ """Lookup all members of the team with optional precaching.
667+
668+ :param store: Provide ability to specify the store.
669+ :param origin: List of storm tables and joins. This list will be
670+ appended to. The Person table is required.
671+ :param conditions: Storm conditions for tables in origin.
672+ :param need_karma: The karma attribute will be cached.
673+ :param need_ubuntu_coc: The is_ubuntu_coc_signer attribute will be
674+ cached.
675+ :param need_location: The location attribute will be cached.
676+ :param need_archive: The archive attribute will be cached.
677+ :param need_preferred_email: The preferred email attribute will be
678+ cached.
679+ :param need_validity: The is_valid attribute will be cached.
680+ """
681+ if store is None:
682+ store = IStore(Person)
683+ columns = [Person]
684+ decorators = []
685+ if need_karma:
686+ # New people have no karmatotalcache rows.
687+ origin.append(
688+ LeftJoin(KarmaTotalCache,
689+ KarmaTotalCache.person == Person.id))
690+ columns.append(KarmaTotalCache)
691+ if need_ubuntu_coc:
692+ columns.append(Alias(Exists(Select(SignedCodeOfConduct,
693+ And(Person._is_ubuntu_coc_signer_condition(),
694+ SignedCodeOfConduct.ownerID == Person.id))),
695+ name='is_ubuntu_coc_signer'))
696+ if need_location:
697+ # New people have no location rows
698+ origin.append(
699+ LeftJoin(PersonLocation,
700+ PersonLocation.person == Person.id))
701+ columns.append(PersonLocation)
702+ if need_archive:
703+ # Not everyone has PPA's
704+ # It would be nice to cleanly expose the soyuz rules for this to
705+ # avoid duplicating the relationships.
706+ origin.append(
707+ LeftJoin(Archive, Archive.owner == Person.id))
708+ columns.append(Archive)
709+ conditions = And(conditions,
710+ Or(Archive.id == None, And(
711+ Archive.id == Select(Min(Archive.id),
712+ Archive.owner == Person.id, Archive),
713+ Archive.purpose == ArchivePurpose.PPA)))
714+ # checking validity requires having a preferred email.
715+ if need_preferred_email and not need_validity:
716+ # Teams don't have email, so a left join
717+ origin.append(
718+ LeftJoin(EmailAddress, EmailAddress.person == Person.id))
719+ columns.append(EmailAddress)
720+ conditions = And(conditions,
721+ Or(EmailAddress.status == None,
722+ EmailAddress.status == EmailAddressStatus.PREFERRED))
723+ if need_validity:
724+ valid_stuff = Person._validity_queries()
725+ origin.extend(valid_stuff["joins"])
726+ columns.extend(valid_stuff["tables"])
727+ decorators.extend(valid_stuff["decorators"])
728+ if len(columns) == 1:
729+ columns = columns[0]
730+ # Return a simple ResultSet
731+ return store.using(*origin).find(columns, conditions)
732+ # Adapt the result into a cached Person.
733+ columns = tuple(columns)
734+ raw_result = store.using(*origin).find(columns, conditions)
735+
736+ def prepopulate_person(row):
737+ result = row[0]
738+ cache = get_property_cache(result)
739+ index = 1
740+ #-- karma caching
741+ if need_karma:
742+ karma = row[index]
743+ index += 1
744+ if karma is None:
745+ karma_total = 0
746+ else:
747+ karma_total = karma.karma_total
748+ cache.karma = karma_total
749+ #-- ubuntu code of conduct signer status caching.
750+ if need_ubuntu_coc:
751+ signed = row[index]
752+ index += 1
753+ cache.is_ubuntu_coc_signer = signed
754+ #-- location caching
755+ if need_location:
756+ location = row[index]
757+ index += 1
758+ cache.location = location
759+ #-- archive caching
760+ if need_archive:
761+ archive = row[index]
762+ index += 1
763+ cache.archive = archive
764+ #-- preferred email caching
765+ if need_preferred_email and not need_validity:
766+ email = row[index]
767+ index += 1
768+ cache.preferredemail = email
769+ for decorator in decorators:
770+ column = row[index]
771+ index += 1
772+ decorator(result, column)
773+ return result
774+ return DecoratedResultSet(raw_result,
775+ result_decorator=prepopulate_person)
776+
777
778 # Provide a storm alias from Person to Owner. This is useful in queries on
779 # objects that have more than just an owner associated with them.
780
781=== modified file 'lib/lp/registry/tests/test_person.py'
782--- lib/lp/registry/tests/test_person.py 2010-11-30 22:01:15 +0000
783+++ lib/lp/registry/tests/test_person.py 2010-12-09 15:28:31 +0000
784@@ -67,6 +67,7 @@
785 login_person,
786 logout,
787 person_logged_in,
788+ StormStatementRecorder,
789 TestCase,
790 TestCaseWithFactory,
791 )
792@@ -428,12 +429,12 @@
793 self.assertEqual('(\\u0170-tester)>', displayname)
794
795
796-class TestPersonSet(TestCase):
797+class TestPersonSet(TestCaseWithFactory):
798 """Test `IPersonSet`."""
799 layer = DatabaseFunctionalLayer
800
801 def setUp(self):
802- TestCase.setUp(self)
803+ super(TestPersonSet, self).setUp()
804 login(ANONYMOUS)
805 self.addCleanup(logout)
806 self.person_set = getUtility(IPersonSet)
807@@ -457,6 +458,31 @@
808 "PersonSet.getByEmail() should ignore case and whitespace.")
809 self.assertEqual(person1, person2)
810
811+ def test_getPrecachedPersonsFromIDs(self):
812+ # The getPrecachedPersonsFromIDs() method should only make one
813+ # query to load all the extraneous data. Accessing the
814+ # attributes should then cause zero queries.
815+ person_ids = [
816+ self.factory.makePerson().id
817+ for i in range(3)]
818+
819+ with StormStatementRecorder() as recorder:
820+ persons = list(self.person_set.getPrecachedPersonsFromIDs(
821+ person_ids, need_karma=True, need_ubuntu_coc=True,
822+ need_location=True, need_archive=True,
823+ need_preferred_email=True, need_validity=True))
824+ self.assertThat(recorder, HasQueryCount(LessThan(2)))
825+
826+ with StormStatementRecorder() as recorder:
827+ for person in persons:
828+ person.is_valid_person
829+ person.karma
830+ person.is_ubuntu_coc_signer
831+ person.location
832+ person.archive
833+ person.preferredemail
834+ self.assertThat(recorder, HasQueryCount(LessThan(1)))
835+
836
837 class KarmaTestMixin:
838 """Helper methods for setting karma."""