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
=== 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 19:38:45 +0000
@@ -9,19 +9,92 @@
9import os9import os
10import unittest10import unittest
1111
12from storm.store import Store
13from testtools.matchers import LessThan
14
12from canonical.launchpad.testing.systemdocs import (15from canonical.launchpad.testing.systemdocs import (
13 LayeredDocFileSuite,16 LayeredDocFileSuite,
14 setUp,17 setUp,
15 tearDown,18 tearDown,
16 )19 )
20from canonical.launchpad.webapp import canonical_url
17from canonical.testing import DatabaseFunctionalLayer21from canonical.testing import DatabaseFunctionalLayer
1822from lp.testing import (
1923 login,
20here = os.path.dirname(os.path.realpath(__file__))24 logout,
2125 TestCaseWithFactory,
26 )
27from lp.testing.matchers import HasQueryCount
28from lp.testing.sampledata import ADMIN_EMAIL
29from lp.testing._webservice import QueryCollector
30
31
32class 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
2294
23def test_suite():95def test_suite():
24 suite = unittest.TestSuite()96 suite = unittest.TestLoader().loadTestsFromName(__name__)
97 here = os.path.dirname(os.path.realpath(__file__))
25 testsdir = os.path.abspath(here)98 testsdir = os.path.abspath(here)
2699
27 # Add tests using default setup/teardown100 # Add tests using default setup/teardown
28101
=== modified file 'lib/lp/blueprints/model/specification.py'
--- lib/lp/blueprints/model/specification.py 2010-08-20 20:31:18 +0000
+++ lib/lp/blueprints/model/specification.py 2010-08-31 19:38:45 +0000
@@ -24,6 +24,14 @@
24 SQLRelatedJoin,24 SQLRelatedJoin,
25 StringCol,25 StringCol,
26 )26 )
27from storm.expr import (
28 LeftJoin,
29 )
30from storm.locals import (
31 ClassAlias,
32 Desc,
33 SQL,
34 )
27from storm.store import Store35from storm.store import Store
28from zope.event import notify36from zope.event import notify
29from zope.interface import implements37from zope.interface import implements
@@ -40,6 +48,9 @@
40 SQLBase,48 SQLBase,
41 sqlvalues,49 sqlvalues,
42 )50 )
51from canonical.launchpad.components.decoratedresultset import (
52 DecoratedResultSet,
53 )
43from canonical.launchpad.helpers import (54from canonical.launchpad.helpers import (
44 get_contact_email_addresses,55 get_contact_email_addresses,
45 shortlist,56 shortlist,
@@ -680,6 +691,73 @@
680 # this should be implemented by the actual context class691 # this should be implemented by the actual context class
681 raise NotImplementedError692 raise NotImplementedError
682693
694 def _specification_sort(self, sort):
695 """Return the storm sort order for 'specifications'.
696
697 :param sort: As per HasSpecificationsMixin.specifications.
698 """
699 # sort by priority descending, by default
700 if sort is None or sort == SpecificationSort.PRIORITY:
701 return (
702 Desc(Specification.priority), Specification.definition_status,
703 Specification.name)
704 elif sort == SpecificationSort.DATE:
705 return (Desc(Specification.datecreated), Specification.id)
706
707 def _preload_specifications_people(self, query):
708 """Perform eager loading of people and their validity for query.
709
710 :param query: a string query generated in the 'specifications'
711 method.
712 :return: A DecoratedResultSet with Person precaching setup.
713 """
714 # Circular import.
715 from lp.registry.model.person import Person
716 assignee = ClassAlias(Person, "assignee")
717 approver = ClassAlias(Person, "approver")
718 drafter = ClassAlias(Person, "drafter")
719 origin = [Specification,
720 LeftJoin(assignee, Specification.assignee==assignee.id),
721 LeftJoin(approver, Specification.approver==approver.id),
722 LeftJoin(drafter, Specification.drafter==drafter.id),
723 ]
724 columns = [Specification, assignee, approver, drafter]
725 for alias in (assignee, approver, drafter):
726 validity_info = Person._validity_queries(person_table=alias)
727 origin.extend(validity_info["joins"])
728 columns.extend(validity_info["tables"])
729 # We only need one decorators list: all the decorators will be
730 # bound the same, and having a single list lets us more easily call
731 # the right one.
732 decorators = validity_info["decorators"]
733 columns = tuple(columns)
734 results = Store.of(self).using(*origin).find(
735 columns,
736 SQL(query),
737 )
738 def cache_person(person, row, start_index):
739 """apply caching decorators to person."""
740 index = start_index
741 for decorator in decorators:
742 column = row[index]
743 index += 1
744 decorator(person, column)
745 return index
746 def cache_validity(row):
747 # Assignee
748 person = row[1]
749 # After drafter
750 index = 4
751 index = cache_person(person, row, index)
752 # approver
753 person = row[2]
754 index = cache_person(person, row, index)
755 # drafter
756 person = row[3]
757 index = cache_person(person, row, index)
758 return row[0]
759 return DecoratedResultSet(results, cache_validity)
760
683 @property761 @property
684 def valid_specifications(self):762 def valid_specifications(self):
685 """See IHasSpecifications."""763 """See IHasSpecifications."""
686764
=== modified file 'lib/lp/registry/model/distribution.py'
--- lib/lp/registry/model/distribution.py 2010-08-24 15:29:01 +0000
+++ lib/lp/registry/model/distribution.py 2010-08-31 19:38:45 +0000
@@ -810,13 +810,7 @@
810 # defaults for informationalness: we don't have to do anything810 # defaults for informationalness: we don't have to do anything
811 # because the default if nothing is said is ANY811 # because the default if nothing is said is ANY
812812
813 # sort by priority descending, by default813 order = self._specification_sort(sort)
814 if sort is None or sort == SpecificationSort.PRIORITY:
815 order = (
816 ['-priority', 'Specification.definition_status',
817 'Specification.name'])
818 elif sort == SpecificationSort.DATE:
819 order = ['-Specification.datecreated', 'Specification.id']
820814
821 # figure out what set of specifications we are interested in. for815 # figure out what set of specifications we are interested in. for
822 # distributions, we need to be able to filter on the basis of:816 # distributions, we need to be able to filter on the basis of:
@@ -859,9 +853,15 @@
859 query += ' AND Specification.fti @@ ftq(%s) ' % quote(853 query += ' AND Specification.fti @@ ftq(%s) ' % quote(
860 constraint)854 constraint)
861855
862 results = Specification.select(query, orderBy=order, limit=quantity)
863 if prejoin_people:856 if prejoin_people:
864 results = results.prejoin(['assignee', 'approver', 'drafter'])857 results = self._preload_specifications_people(query)
858 else:
859 results = Store.of(self).find(
860 Specification,
861 SQL(query))
862 results.order_by(order)
863 if quantity is not None:
864 results = results[:quantity]
865 return results865 return results
866866
867 def getSpecification(self, name):867 def getSpecification(self, name):
868868
=== modified file 'lib/lp/registry/model/person.py'
--- lib/lp/registry/model/person.py 2010-08-23 16:51:11 +0000
+++ lib/lp/registry/model/person.py 2010-08-31 19:38:45 +0000
@@ -1172,6 +1172,7 @@
1172 @cachedproperty('_is_valid_person_cached')1172 @cachedproperty('_is_valid_person_cached')
1173 def is_valid_person(self):1173 def is_valid_person(self):
1174 """See `IPerson`."""1174 """See `IPerson`."""
1175 # This is prepopulated by various queries in and out of person.py.
1175 if self.is_team:1176 if self.is_team:
1176 return False1177 return False
1177 try:1178 try:
@@ -1577,6 +1578,67 @@
1577 need_location=True, need_archive=True, need_preferred_email=True,1578 need_location=True, need_archive=True, need_preferred_email=True,
1578 need_validity=True)1579 need_validity=True)
15791580
1581 @staticmethod
1582 def _validity_queries(person_table=None):
1583 """Return storm expressions and a decorator function for validity.
1584
1585 Preloading validity implies preloading preferred email addresses.
1586
1587 :param person_table: The person table to join to. Only supply if
1588 ClassAliases are in use.
1589 :return: A dict with four keys joins, tables, conditions, decorators
1590
1591 * joins are additional joins to use. e.g. [LeftJoin,LeftJoin]
1592 * tables are tables to use e.g. [EmailAddress, Account]
1593 * decorators are callbacks to call for each row. Each decorator takes
1594 (Person, column) where column is the column in the result set for that
1595 decorators type.
1596 """
1597 if person_table is None:
1598 person_table = Person
1599 email_table = EmailAddress
1600 account_table = Account
1601 else:
1602 email_table = ClassAlias(EmailAddress)
1603 account_table = ClassAlias(Account)
1604 origins = []
1605 columns = []
1606 decorators = []
1607 # Teams don't have email, so a left join
1608 origins.append(
1609 LeftJoin(email_table, And(
1610 email_table.personID == person_table.id,
1611 email_table.status == EmailAddressStatus.PREFERRED)))
1612 columns.append(email_table)
1613 origins.append(
1614 LeftJoin(account_table, And(
1615 person_table.accountID == account_table.id,
1616 account_table.status == AccountStatus.ACTIVE)))
1617 columns.append(account_table)
1618 def handleemail(person, column):
1619 #-- preferred email caching
1620 if not person:
1621 return
1622 email = column
1623 cache_property(person, '_preferredemail_cached', email)
1624 decorators.append(handleemail)
1625 def handleaccount(person, column):
1626 #-- validity caching
1627 if not person:
1628 return
1629 # valid if:
1630 valid = (
1631 # -- valid account found
1632 column is not None
1633 # -- preferred email found
1634 and person.preferredemail is not None)
1635 cache_property(person, '_is_valid_person_cached', valid)
1636 decorators.append(handleaccount)
1637 return dict(
1638 joins=origins,
1639 tables=columns,
1640 decorators=decorators)
1641
1580 def _all_members(self, need_karma=False, need_ubuntu_coc=False,1642 def _all_members(self, need_karma=False, need_ubuntu_coc=False,
1581 need_location=False, need_archive=False, need_preferred_email=False,1643 need_location=False, need_archive=False, need_preferred_email=False,
1582 need_validity=False):1644 need_validity=False):
@@ -1606,6 +1668,7 @@
1606 # But not the team itself.1668 # But not the team itself.
1607 TeamParticipation.person != self.id)1669 TeamParticipation.person != self.id)
1608 columns = [Person]1670 columns = [Person]
1671 decorators = []
1609 if need_karma:1672 if need_karma:
1610 # New people have no karmatotalcache rows.1673 # New people have no karmatotalcache rows.
1611 origin.append(1674 origin.append(
@@ -1636,7 +1699,7 @@
1636 Archive.owner == Person.id, Archive),1699 Archive.owner == Person.id, Archive),
1637 Archive.purpose == ArchivePurpose.PPA)))1700 Archive.purpose == ArchivePurpose.PPA)))
1638 # checking validity requires having a preferred email.1701 # checking validity requires having a preferred email.
1639 if need_preferred_email or need_validity:1702 if need_preferred_email and not need_validity:
1640 # Teams don't have email, so a left join1703 # Teams don't have email, so a left join
1641 origin.append(1704 origin.append(
1642 LeftJoin(EmailAddress, EmailAddress.person == Person.id))1705 LeftJoin(EmailAddress, EmailAddress.person == Person.id))
@@ -1645,14 +1708,10 @@
1645 Or(EmailAddress.status == None,1708 Or(EmailAddress.status == None,
1646 EmailAddress.status == EmailAddressStatus.PREFERRED))1709 EmailAddress.status == EmailAddressStatus.PREFERRED))
1647 if need_validity:1710 if need_validity:
1648 # May find teams (teams are not valid people)1711 valid_stuff = Person._validity_queries()
1649 origin.append(1712 origin.extend(valid_stuff["joins"])
1650 LeftJoin(Account, Person.account == Account.id))1713 columns.extend(valid_stuff["tables"])
1651 columns.append(Account)1714 decorators.extend(valid_stuff["decorators"])
1652 conditions = And(conditions,
1653 Or(
1654 Account.status == None,
1655 Account.status == AccountStatus.ACTIVE))
1656 if len(columns) == 1:1715 if len(columns) == 1:
1657 columns = columns[0]1716 columns = columns[0]
1658 # Return a simple ResultSet1717 # Return a simple ResultSet
@@ -1689,20 +1748,14 @@
1689 index += 11748 index += 1
1690 cache_property(result, '_archive_cached', archive)1749 cache_property(result, '_archive_cached', archive)
1691 #-- preferred email caching1750 #-- preferred email caching
1692 if need_preferred_email:1751 if need_preferred_email and not need_validity:
1693 email = row[index]1752 email = row[index]
1694 index += 11753 index += 1
1695 cache_property(result, '_preferredemail_cached', email)1754 cache_property(result, '_preferredemail_cached', email)
1696 #-- validity caching1755 for decorator in decorators:
1697 if need_validity:1756 column = row[index]
1698 # valid if:
1699 valid = (
1700 # -- valid account found
1701 row[index] is not None
1702 # -- preferred email found
1703 and result.preferredemail is not None)
1704 index += 11757 index += 1
1705 cache_property(result, '_is_valid_person_cached', valid)1758 decorator(result, column)
1706 return result1759 return result
1707 return DecoratedResultSet(raw_result,1760 return DecoratedResultSet(raw_result,
1708 result_decorator=prepopulate_person)1761 result_decorator=prepopulate_person)
17091762
=== modified file 'lib/lp/registry/model/product.py'
--- lib/lp/registry/model/product.py 2010-08-23 18:03:26 +0000
+++ lib/lp/registry/model/product.py 2010-08-31 19:38:45 +0000
@@ -1033,13 +1033,7 @@
1033 # defaults for informationalness: we don't have to do anything1033 # defaults for informationalness: we don't have to do anything
1034 # because the default if nothing is said is ANY1034 # because the default if nothing is said is ANY
10351035
1036 # sort by priority descending, by default1036 order = self._specification_sort(sort)
1037 if sort is None or sort == SpecificationSort.PRIORITY:
1038 order = (
1039 ['-priority', 'Specification.definition_status',
1040 'Specification.name'])
1041 elif sort == SpecificationSort.DATE:
1042 order = ['-Specification.datecreated', 'Specification.id']
10431037
1044 # figure out what set of specifications we are interested in. for1038 # figure out what set of specifications we are interested in. for
1045 # products, we need to be able to filter on the basis of:1039 # products, we need to be able to filter on the basis of:
@@ -1082,9 +1076,15 @@
1082 query += ' AND Specification.fti @@ ftq(%s) ' % quote(1076 query += ' AND Specification.fti @@ ftq(%s) ' % quote(
1083 constraint)1077 constraint)
10841078
1085 results = Specification.select(query, orderBy=order, limit=quantity)
1086 if prejoin_people:1079 if prejoin_people:
1087 results = results.prejoin(['assignee', 'approver', 'drafter'])1080 results = self._preload_specifications_people(query)
1081 else:
1082 results = Store.of(self).find(
1083 Specification,
1084 SQL(query))
1085 results.order_by(order)
1086 if quantity is not None:
1087 results = results[:quantity]
1088 return results1088 return results
10891089
1090 def getSpecification(self, name):1090 def getSpecification(self, name):