← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~lifeless/launchpad/bug-607935 into lp:launchpad/devel

 

Robert Collins has proposed merging lp:~lifeless/launchpad/bug-607935 into lp:launchpad/devel.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)


Remove the 2-queries-per-team-membership scaling from BugTask:+index via a new helper on IBug.

Also enhances some test support to allow non-request-based query count assertThat uses.

This was motivated by looking at the queries in the OOPSes on bug 607935.

I don't expect this to fix the timeouts (though I could always be pleasantly surprised), but it should make most bugtask pages about a second faster for users in many teams.
-- 
https://code.launchpad.net/~lifeless/launchpad/bug-607935/+merge/34143
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~lifeless/launchpad/bug-607935 into lp:launchpad/devel.
=== modified file 'lib/lp/bugs/browser/bugtask.py'
--- lib/lp/bugs/browser/bugtask.py	2010-08-27 05:34:32 +0000
+++ lib/lp/bugs/browser/bugtask.py	2010-08-30 23:53:41 +0000
@@ -668,23 +668,26 @@
             return
 
         # Set up widgets in order to handle subscription requests.
-        if bug.isSubscribed(self.user) or bug.isSubscribedToDupes(self.user):
-            subscription_terms = [
-                SimpleTerm(
-                    self.user, self.user.name,
-                    'Unsubscribe me from this bug')]
-        else:
-            subscription_terms = [
-                SimpleTerm(
-                    self.user, self.user.name, 'Subscribe me to this bug')]
-        for team in self.user.teams_participated_in:
-            if (bug.isSubscribed(team) or bug.isSubscribedToDupes(team)):
-                subscription_terms.append(
-                    SimpleTerm(
-                        team, team.name,
+        subscription_terms = []
+        self_subscribed = False
+        for person in bug.getSubscribersForPerson(self.user):
+            if person.id == self.user.id:
+                subscription_terms.append(
+                    SimpleTerm(
+                        person, person,
+                        'Unsubscribe me from this bug'))
+                self_subscribed = True
+            else:
+                subscription_terms.append(
+                    SimpleTerm(
+                        person, person.name,
                         'Unsubscribe <a href="%s">%s</a> from this bug' % (
-                            canonical_url(team),
-                            cgi.escape(team.displayname))))
+                            canonical_url(person),
+                            cgi.escape(person.displayname))))
+        if not self_subscribed:
+            subscription_terms.insert(0,
+                SimpleTerm(
+                    self.user, self.user.name, 'Subscribe me to this bug'))
         subscription_vocabulary = SimpleVocabulary(subscription_terms)
         person_field = Choice(
             __name__='subscription',

=== modified file 'lib/lp/bugs/browser/tests/test_bugtask.py'
--- lib/lp/bugs/browser/tests/test_bugtask.py	2010-08-20 20:31:18 +0000
+++ lib/lp/bugs/browser/tests/test_bugtask.py	2010-08-30 23:53:41 +0000
@@ -1,12 +1,21 @@
 # Copyright 2009 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
+from __future__ import with_statement
+
 __metaclass__ = type
 
 
+from contextlib import nested
 from doctest import DocTestSuite
 import unittest
 
+from storm.store import Store
+from testtools.matchers import (
+    Equals,
+    LessThan,
+    MatchesAny,
+    )
 from zope.security.proxy import removeSecurityProxy
 
 from canonical.launchpad.ftests import (
@@ -23,11 +32,66 @@
 from canonical.testing import LaunchpadFunctionalLayer
 from lp.bugs.browser import bugtask
 from lp.bugs.browser.bugtask import (
+    BugTaskView,
     BugTaskEditView,
     BugTasksAndNominationsView,
     )
 from lp.bugs.interfaces.bugtask import BugTaskStatus
-from lp.testing import TestCaseWithFactory
+from lp.testing import (
+    person_logged_in,
+    StormStatementRecorder,
+    TestCaseWithFactory,
+    )
+from lp.testing.matchers import HasQueryCount
+from lp.testing.sampledata import (
+    ADMIN_EMAIL,
+    NO_PRIVILEGE_EMAIL,
+    USER_EMAIL,
+    )
+
+
+class TestBugTaskView(TestCaseWithFactory):
+
+    layer = LaunchpadFunctionalLayer
+
+    def initialize_view(self, bugtask, person):
+        self.invalidate_caches(bugtask)
+        # Login first because logging in triggers queries.
+        with nested(person_logged_in(person), StormStatementRecorder()) as (
+            _,
+            recorder):
+            view = BugTaskView(bugtask, LaunchpadTestRequest())
+            view.initialize()
+        return recorder
+
+    def invalidate_caches(self, obj):
+        store = Store.of(obj)
+        # Make sure everything is in the database.
+        store.flush()
+        # And invalidate the cache (not a reset, because that stops us using
+        # the domain objects)
+        store.invalidate()
+
+    def test_query_counts_constant_with_team_memberships(self):
+        login(ADMIN_EMAIL)
+        bugtask = self.factory.makeBugTask()
+        person_no_teams = self.factory.makePerson()
+        person_with_teams = self.factory.makePerson()
+        for _ in range(4):
+            team = self.factory.makeTeam()
+            team.addMember(person_with_teams, team.teamowner)
+        # count with no teams
+        recorder = self.initialize_view(bugtask, person_no_teams)
+        self.assertThat(recorder, HasQueryCount(LessThan(14)))
+        count_with_no_teams = recorder.count
+        # count with 2 teams
+        recorder2 = self.initialize_view(bugtask, person_with_teams)
+        # Allow an increase of one because storm bug 619017 causes additional
+        # queries, revalidating things unnecessarily. An increase of 1 from 
+        # four new teams shows it is definitely not growing per-team.
+        self.assertThat(recorder2, HasQueryCount(
+            LessThan(count_with_no_teams + 2),
+            ))
 
 
 class TestBugTasksAndNominationsView(TestCaseWithFactory):
@@ -36,7 +100,7 @@
 
     def setUp(self):
         super(TestBugTasksAndNominationsView, self).setUp()
-        login('foo.bar@xxxxxxxxxxxxx')
+        login(ADMIN_EMAIL)
         self.bug = self.factory.makeBug()
         self.view = BugTasksAndNominationsView(
             self.bug, LaunchpadTestRequest())
@@ -248,7 +312,7 @@
     def test_status_field_items_for_ordinary_users(self):
         # Ordinary users can set the status to all values except Won't fix,
         # Expired, Triaged, Unknown.
-        login('no-priv@xxxxxxxxxxxxx')
+        login(NO_PRIVILEGE_EMAIL)
         view = BugTaskEditView(
             self.bug.default_bugtask, LaunchpadTestRequest())
         view.initialize()
@@ -282,7 +346,7 @@
         login_person(owner)
         self.bug.default_bugtask.transitionToStatus(
             BugTaskStatus.UNKNOWN, owner)
-        login('no-priv@xxxxxxxxxxxxx')
+        login(NO_PRIVILEGE_EMAIL)
         view = BugTaskEditView(
             self.bug.default_bugtask, LaunchpadTestRequest())
         view.initialize()
@@ -296,7 +360,7 @@
         # in the options.
         removeSecurityProxy(self.bug.default_bugtask).status = (
             BugTaskStatus.EXPIRED)
-        login('no-priv@xxxxxxxxxxxxx')
+        login(NO_PRIVILEGE_EMAIL)
         view = BugTaskEditView(
             self.bug.default_bugtask, LaunchpadTestRequest())
         view.initialize()
@@ -317,7 +381,7 @@
     def test_assignee_field_vocabulary_regular_user(self):
         # For regular users, the assignee vocabulary is
         # AllUserTeamsParticipation.
-        login('test@xxxxxxxxxxxxx')
+        login(USER_EMAIL)
         view = BugTaskEditView(self.bugtask, LaunchpadTestRequest())
         view.initialize()
         self.assertEqual(
@@ -336,10 +400,7 @@
 
 
 def test_suite():
-    suite = unittest.TestSuite()
-    suite.addTest(unittest.makeSuite(TestBugTasksAndNominationsView))
-    suite.addTest(unittest.makeSuite(TestBugTaskEditViewStatusField))
-    suite.addTest(unittest.makeSuite(TestBugTaskEditViewAssigneeField))
+    suite = unittest.TestLoader().loadTestsFromName(__name__)
     suite.addTest(DocTestSuite(bugtask))
     suite.addTest(LayeredDocFileSuite(
         'bugtask-target-link-titles.txt', setUp=setUp, tearDown=tearDown,

=== modified file 'lib/lp/bugs/configure.zcml'
--- lib/lp/bugs/configure.zcml	2010-08-29 23:35:09 +0000
+++ lib/lp/bugs/configure.zcml	2010-08-30 23:53:41 +0000
@@ -640,6 +640,7 @@
                     isSubscribedToDupes
                     getSubscribersFromDuplicates
                     getSubscriptionsFromDuplicates
+                    getSubscribersForPerson
                     indexed_messages
                     getAlsoNotifiedSubscribers
                     getBugWatch

=== modified file 'lib/lp/bugs/interfaces/bug.py'
--- lib/lp/bugs/interfaces/bug.py	2010-08-29 22:13:26 +0000
+++ lib/lp/bugs/interfaces/bug.py	2010-08-30 23:53:41 +0000
@@ -489,6 +489,14 @@
     def getSubscribersFromDuplicates():
         """Return IPersons subscribed from dupes of this bug."""
 
+    def getSubscribersForPerson(person):
+        """Find the persons or teams by which person is subscribed.
+        
+        This call should be quite cheap to make and performs a single query.
+        
+        :return: An IResultSet.
+        """
+
     def getBugNotificationRecipients(duplicateof=None, old_bug=None,
                                      include_master_dupe_subscribers=False):
         """Return a complete INotificationRecipientSet instance.

=== modified file 'lib/lp/bugs/model/bug.py'
--- lib/lp/bugs/model/bug.py	2010-08-29 22:05:15 +0000
+++ lib/lp/bugs/model/bug.py	2010-08-30 23:53:41 +0000
@@ -187,6 +187,7 @@
     ValidPersonCache,
     )
 from lp.registry.model.pillar import pillar_sort_key
+from lp.registry.model.teammembership import TeamParticipation
 from lp.services.fields import DuplicateBug
 
 
@@ -781,6 +782,33 @@
         return sorted(
             dupe_subscribers, key=operator.attrgetter("displayname"))
 
+    def getSubscribersForPerson(self, person):
+        """See `IBug."""
+        assert person is not None
+        return Store.of(self).find(
+            # return people
+            Person,
+            # For this bug or its duplicates
+            Or(
+                Bug.id == self.id,
+                Bug.duplicateof == self.id),
+            # Get subscriptions for these bugs
+            BugSubscription.bug == Bug.id,
+            # Filter by subscriptions to any team person is in.
+            # Note that teamparticipation includes self-participation entries
+            # (person X is in the team X)
+            TeamParticipation.person == person.id,
+            # XXX: Storm fails to compile this, so manually done.
+            # bug=https://bugs.edge.launchpad.net/storm/+bug/627137
+            # RBC 20100831
+            SQL("""TeamParticipation.team = BugSubscription.person"""),
+            # Join in the Person rows we want
+            # XXX: Storm fails to compile this, so manually done.
+            # bug=https://bugs.edge.launchpad.net/storm/+bug/627137
+            # RBC 20100831
+            SQL("""Person.id = TeamParticipation.team"""),
+            )
+
     def getAlsoNotifiedSubscribers(self, recipients=None, level=None):
         """See `IBug`.
 

=== added file 'lib/lp/bugs/tests/test_bug.py'
--- lib/lp/bugs/tests/test_bug.py	1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/tests/test_bug.py	2010-08-30 23:53:41 +0000
@@ -0,0 +1,79 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+from __future__ import with_statement
+
+__metaclass__ = type
+
+from canonical.testing import DatabaseFunctionalLayer
+from lp.testing import (
+    person_logged_in,
+    TestCaseWithFactory,
+    )
+
+
+class TestBug(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_get_subscribers_for_person_unsubscribed(self):
+        bug = self.factory.makeBug()
+        person = self.factory.makePerson()
+        self.assertTrue(bug.getSubscribersForPerson(person).is_empty())
+
+    def test_get_subscribers_for_person_direct_subscription(self):
+        bug = self.factory.makeBug()
+        person = self.factory.makePerson()
+        with person_logged_in(person):
+            bug.subscribe(person, person)
+        self.assertEqual([person], list(bug.getSubscribersForPerson(person)))
+
+    def test_get_subscribers_for_person_indirect_subscription(self):
+        bug = self.factory.makeBug()
+        person = self.factory.makePerson()
+        team1 = self.factory.makeTeam()
+        with person_logged_in(team1.teamowner):
+            team1.addMember(person, team1.teamowner)
+        team2 = self.factory.makeTeam()
+        with person_logged_in(team2.teamowner):
+            team2.addMember(person, team2.teamowner)
+        with person_logged_in(person):
+            bug.subscribe(team1, person)
+        self.assertEqual([team1], list(bug.getSubscribersForPerson(person)))
+
+    def test_get_subscribers_for_person_many_subscriptions(self):
+        bug = self.factory.makeBug()
+        person = self.factory.makePerson()
+        team1 = self.factory.makeTeam()
+        with person_logged_in(team1.teamowner):
+            team1.addMember(person, team1.teamowner)
+        team2 = self.factory.makeTeam()
+        with person_logged_in(team2.teamowner):
+            team2.addMember(person, team2.teamowner)
+        with person_logged_in(person):
+            bug.subscribe(team1, person)
+            bug.subscribe(team2, person)
+            bug.subscribe(person, person)
+        self.assertEqual(
+            set([person, team1, team2]),
+            set(bug.getSubscribersForPerson(person)))
+
+    def test_get_subscribers_for_person_from_duplicates_too(self):
+        bug = self.factory.makeBug()
+        real_bug = self.factory.makeBug()
+        person = self.factory.makePerson()
+        team1 = self.factory.makeTeam()
+        with person_logged_in(team1.teamowner):
+            team1.addMember(person, team1.teamowner)
+        team2 = self.factory.makeTeam()
+        with person_logged_in(team2.teamowner):
+            team2.addMember(person, team2.teamowner)
+        with person_logged_in(person):
+            bug.subscribe(team1, person)
+            bug.subscribe(team2, person)
+            bug.subscribe(person, person)
+            bug.markAsDuplicate(real_bug)
+        self.assertEqual(
+            set([person, team1, team2]),
+            set(real_bug.getSubscribersForPerson(person)))
+

=== modified file 'lib/lp/testing/__init__.py'
--- lib/lp/testing/__init__.py	2010-08-27 11:20:55 +0000
+++ lib/lp/testing/__init__.py	2010-08-30 23:53:41 +0000
@@ -33,6 +33,7 @@
     'run_with_login',
     'run_with_storm_debug',
     'run_script',
+    'StormStatementRecorder',
     'TestCase',
     'TestCaseWithFactory',
     'test_tales',
@@ -220,11 +221,53 @@
 
 
 class StormStatementRecorder:
-    """A storm tracer to count queries."""
+    """A storm tracer to count queries.
+    
+    This exposes the count and queries as lp.testing._webservice.QueryCollector
+    does permitting its use with the HasQueryCount matcher.
+
+    It also meets the context manager protocol, so you can gather queries
+    easily:
+    with StormStatementRecorder() as recorder:
+        do somestuff
+    self.assertThat(recorder, HasQueryCount(LessThan(42)))
+
+    Note that due to the storm API used, only one of these recorders may be in
+    place at a time: all will be removed when the first one is removed (by
+    calling __exit__ or leaving the scope of a with statement).
+    """
 
     def __init__(self):
         self.statements = []
 
+    @property
+    def count(self):
+        return len(self.statements)
+
+    @property
+    def queries(self):
+        """The statements executed as per get_request_statements."""
+        # Perhaps we could just consolidate this code with the request tracer
+        # code and not need a custom tracer at all - if we provided a context
+        # factory to the tracer, which in the production tracers would
+        # use the adapter magic, and in test created ones would log to a list.
+        # We would need to be able to remove just one tracer though, which I
+        # haven't looked into yet. RBC 20100831
+        result = []
+        for statement in self.statements:
+            result.append((0, 0, 'unknown', statement))
+        return result
+
+    def __enter__(self):
+        """Context manager protocol - return this object as the context."""
+        install_tracer(self)
+        return self
+
+    def __exit__(self, _ignored, _ignored2, _ignored3):
+        """Content manager protocol - do not swallow exceptions."""
+        remove_tracer_type(StormStatementRecorder)
+        return False
+
     def connection_raw_execute(self, ignored, raw_cursor, statement, params):
         """Increment the counter.  We don't care about the args."""
 
@@ -244,12 +287,8 @@
     :return: a tuple containing the return value of the function,
         and a list of sql statements.
     """
-    recorder = StormStatementRecorder()
-    try:
-        install_tracer(recorder)
+    with StormStatementRecorder() as recorder:
         ret = function(*args, **kwargs)
-    finally:
-        remove_tracer_type(StormStatementRecorder)
     return (ret, recorder.statements)