← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~wallyworld/launchpad/affiliation-query-count into lp:launchpad

 

Ian Booth has proposed merging lp:~wallyworld/launchpad/affiliation-query-count into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #838604 in Launchpad itself: "Pillar affiliation query count too high"
  https://bugs.launchpad.net/launchpad/+bug/838604

For more details, see:
https://code.launchpad.net/~wallyworld/launchpad/affiliation-query-count/+merge/73630

When determining the pillar affiliation for a batch of people in the person picker, the affiliation adaptor checks for membership of various teams eg pillar owner, pillar drivers. It does this one person/one team at a time. This results in potentially one query per check, which could be 24 or greater depending on picker batch size and pillar.

This branch makes the affiliation check more efficient.

== Implementation ==

The basis of the problem is: we have a set of people and some teams and we need to find which of those teams the people belong to. A new method is provided for this purpose in module lp.registry.model.teammembership - find_team_participations(people, teams=None). This method will use (at most) 1 query to return a dict of person->set(teams).

The affiliation business logic has been refactored to use the above code to look up the team based affiliations.

== Tests ==

Tests are provided for find_team_participations in a new test case TestTeamParticipationQuery
The existing pillar affiliation tests are sufficient for the new affiliation logic because the external behaviour has not changed.

== Lint ==

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/registry/model/pillaraffiliation.py
  lib/lp/registry/model/teammembership.py
  lib/lp/registry/tests/test_teammembership.p
-- 
https://code.launchpad.net/~wallyworld/launchpad/affiliation-query-count/+merge/73630
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wallyworld/launchpad/affiliation-query-count into lp:launchpad.
=== modified file 'lib/lp/registry/model/pillaraffiliation.py'
--- lib/lp/registry/model/pillaraffiliation.py	2011-08-18 20:35:41 +0000
+++ lib/lp/registry/model/pillaraffiliation.py	2011-09-01 06:27:28 +0000
@@ -34,6 +34,7 @@
 from lp.registry.interfaces.distributionsourcepackage import (
     IDistributionSourcePackage,
     )
+from lp.registry.model.teammembership import find_team_participations
 
 
 class IHasAffiliation(Interface):
@@ -61,36 +62,71 @@
 
     implements(IHasAffiliation)
 
+    # We rank the affiliations from most important to least important.
+    # Unlisted roles are given a rank of 10.
+    affiliation_priorities = {
+        'maintainer': 1,
+        'driver': 2,
+        'bug supervisor': 3,
+        'security contact': 4,
+    }
+
     def __init__(self, context):
         self.context = context
 
     def getPillar(self):
         return self.context
 
-    def _getAffiliationDetails(self, person, pillar):
+    def _getAffiliation(self, person, pillar):
         """ Return the affiliation information for a person, if any.
 
+        Subclasses will override this method to perform specific affiliation
+        checks.
+        The return result is a list of tuples (pillar displayanme, role).
+        """
+        return []
+
+    def _getAffiliationTeamRoles(self, pillar):
+        """ Return teams for which a person needs to belong, if affiliated.
+
         A person is affiliated with a pillar if they are in the list of
         drivers or are the maintainer.
-        The return result is a list of tuples (pillar displayanme, role).
         """
-        result = []
-        if person.inTeam(pillar.owner):
-            result.append((pillar.displayname, 'maintainer'))
-        for driver in pillar.drivers:
-            if person.inTeam(driver):
-                result.append((pillar.displayname, 'driver'))
-                break
+        result = {
+            (pillar.displayname, 'maintainer'): [pillar.owner],
+            (pillar.displayname, 'driver'): pillar.drivers}
         return result
 
     def getAffiliationBadges(self, persons):
         """ Return the affiliation badge details for people given a context.
+
+        There are 2 ways we check for affiliation:
+        1. Generic membership checks of particular teams as returned by
+           _getAffiliationTeamRoles
+        2. Specific affiliation checks as performed by _getAffiliation
         """
         pillar = self.getPillar()
         result = []
+
+        # We find the teams to check for participation..
+        affiliation_team_details = self._getAffiliationTeamRoles(pillar)
+        teams_to_check = set()
+        for teams in affiliation_team_details.values():
+            teams_to_check.update(teams)
+        # We gather the participation for the persons.
+        people_teams = find_team_participations(persons, teams_to_check)
+
         for person in persons:
-            affiliation_details = self._getAffiliationDetails(person, pillar)
-            if not affiliation_details:
+            # Specific affiliations
+            affiliations = self._getAffiliation(person, pillar)
+            # Generic, team based affiliations
+            affiliated_teams = people_teams.get(person, [])
+            for affiliated_team in affiliated_teams:
+                for affiliation, teams in affiliation_team_details.items():
+                    if affiliated_team in teams:
+                        affiliations.append(affiliation)
+
+            if not affiliations:
                 result.append([])
                 continue
 
@@ -108,8 +144,14 @@
             else:
                 default_icon_url = "/@@/product-badge"
             icon_url = getIconUrl(self.context, pillar, default_icon_url)
+
+            # Sort the affiliation list according the the importance of each
+            # affiliation role.
+            affiliations.sort(
+                key=lambda affiliation_rec:
+                    self.affiliation_priorities.get(affiliation_rec[1], 10))
             badges = []
-            for affiliation in affiliation_details:
+            for affiliation in affiliations:
                 alt_text = "%s %s" % affiliation
                 badges.append(BadgeDetails(icon_url, alt_text))
             result.append(badges)
@@ -121,19 +163,19 @@
     def getPillar(self):
         return self.context.pillar
 
-    def _getAffiliationDetails(self, person, pillar):
+    def _getAffiliationTeamRoles(self, pillar):
         """ A person is affiliated with a bugtask based on (in order):
         - owner of bugtask pillar
         - driver of bugtask pillar
         - bug supervisor of bugtask pillar
         - security contact of bugtask pillar
         """
-        result = super(BugTaskPillarAffiliation, self)._getAffiliationDetails(
-            person, pillar)
-        if person.inTeam(pillar.bug_supervisor):
-            result.append((pillar.displayname, 'bug supervisor'))
-        if person.inTeam(pillar.security_contact):
-            result.append((pillar.displayname, 'security contact'))
+        super_instance = super(BugTaskPillarAffiliation, self)
+        result = super_instance._getAffiliationTeamRoles(pillar)
+        result[(pillar.displayname, 'bug supervisor')] = (
+            [pillar.bug_supervisor])
+        result[(pillar.displayname, 'security contact')] = (
+            [pillar.security_contact])
         return result
 
 
@@ -146,9 +188,9 @@
     def getBranch(self):
         return self.context
 
-    def _getAffiliationDetails(self, person, pillar):
+    def _getAffiliation(self, person, pillar):
         super_instance = super(BranchPillarAffiliation, self)
-        result = super_instance._getAffiliationDetails(person, pillar)
+        result = super_instance._getAffiliation(person, pillar)
         if self.getBranch().isPersonTrustedReviewer(person):
             result.append((pillar.displayname, 'trusted reviewer'))
         return result
@@ -185,18 +227,20 @@
 
 
 class QuestionPillarAffiliation(PillarAffiliation):
-    """An affiliation adapter for questions."""
+    """An affiliation adapter for questions.
+
+    A person is affiliated with a question based on (in order):
+    - answer contact for question target
+    - owner of question target
+    - driver of question target
+    """
+
     def getPillar(self):
         return self.context.product or self.context.distribution
 
-    def _getAffiliationDetails(self, person, pillar):
-        """ A person is affiliated with a question based on (in order):
-        - answer contact for question target
-        - owner of question target
-        - driver of question target
-        """
+    def _getAffiliation(self, person, pillar):
         result = (super(QuestionPillarAffiliation, self)
-                                ._getAffiliationDetails(person, pillar))
+                                ._getAffiliation(person, pillar))
         target = self.context.target
         if IDistributionSourcePackage.providedBy(target):
             question_targets = (target, target.distribution)

=== modified file 'lib/lp/registry/model/teammembership.py'
--- lib/lp/registry/model/teammembership.py	2011-08-25 20:00:19 +0000
+++ lib/lp/registry/model/teammembership.py	2011-09-01 06:27:28 +0000
@@ -5,6 +5,7 @@
 
 __metaclass__ = type
 __all__ = [
+    'find_team_participations',
     'TeamMembership',
     'TeamMembershipSet',
     'TeamParticipation',
@@ -20,6 +21,7 @@
     ForeignKey,
     StringCol,
     )
+from storm.info import ClassAlias
 from storm.store import Store
 from zope.component import getUtility
 from zope.interface import implements
@@ -655,3 +657,64 @@
 
     store = Store.of(member)
     store.execute(query)
+
+
+def find_team_participations(people, teams=None):
+    """Find the teams the given people participate in.
+
+    :param people: The people for which to query team participation.
+    :param teams: Optionally, limit the participation check to these teams.
+
+    This method performs its work with at most a single database query.
+    It first does similar checks to those performed by IPerson.in_team() and
+    it may turn out that no database query is required at all.
+    """
+
+    teams_to_query = []
+    people_teams = {}
+
+    def add_team_to_result(person, team):
+        teams = people_teams.get(person)
+        if teams is None:
+            teams = set()
+            people_teams[person] = teams
+        teams.add(team)
+
+    # Check for the simple cases - self membership etc.
+    if teams:
+        for team in teams:
+            if team is None:
+                continue
+            for person in people:
+                if team.id == person.id:
+                    add_team_to_result(person, team)
+                    continue
+            if not team.is_team:
+                continue
+            teams_to_query.append(team)
+
+    # Avoid circular imports
+    from lp.registry.model.person import Person
+
+    # We are either checking for membership of any team or didn't eliminate
+    # all the specific team participation checks above.
+    if teams_to_query or not teams:
+        Team = ClassAlias(Person, 'Team')
+        person_ids = [person.id for person in people]
+        conditions = [
+            TeamParticipation.personID == Person.id,
+            TeamParticipation.teamID == Team.id,
+            Person.id.is_in(person_ids)
+        ]
+        team_ids = [team.id for team in teams_to_query]
+        if team_ids:
+            conditions.append(Team.id.is_in(team_ids))
+
+        store = IStore(Person)
+        rs = store.find(
+            (Person, Team),
+            *conditions)
+
+        for (person, team) in rs:
+            add_team_to_result(person, team)
+    return people_teams

=== modified file 'lib/lp/registry/tests/test_teammembership.py'
--- lib/lp/registry/tests/test_teammembership.py	2011-08-26 20:00:37 +0000
+++ lib/lp/registry/tests/test_teammembership.py	2011-09-01 06:27:28 +0000
@@ -9,6 +9,7 @@
     )
 import re
 import subprocess
+from testtools.matchers import Equals
 from unittest import (
     TestCase,
     TestLoader,
@@ -52,7 +53,8 @@
     ITeamMembershipSet,
     TeamMembershipStatus,
     )
-from lp.registry.model.teammembership import (
+from lp.registry.model.teammembership import (\
+    find_team_participations,
     TeamMembership,
     TeamParticipation,
     )
@@ -60,8 +62,9 @@
     login_celebrity,
     person_logged_in,
     TestCaseWithFactory,
-    )
+    StormStatementRecorder)
 from lp.testing.mail_helpers import pop_notifications
+from lp.testing.matchers import HasQueryCount
 from lp.testing.storm import reload_object
 
 
@@ -261,6 +264,53 @@
         return IStore(TeamParticipation).find(TeamParticipation).count()
 
 
+class TestTeamParticipationQuery(TeamParticipationTestCase):
+    """A test case for teammembership.test_find_team_participations."""
+
+    def test_find_team_participations(self):
+        # The correct team participations are found and the query count is 1.
+        self.team1.addMember(self.no_priv, self.foo_bar)
+        self.team2.addMember(self.no_priv, self.foo_bar)
+        self.team1.addMember(self.team2, self.foo_bar, force_team_add=True)
+
+        people = [self.team1, self.team2]
+        with StormStatementRecorder() as recorder:
+            people_teams = find_team_participations(people)
+        self.assertThat(recorder, HasQueryCount(Equals(1)))
+        self.assertContentEqual([self.team1, self.team2], people_teams.keys())
+        self.assertContentEqual([self.team1], people_teams[self.team1])
+        self.assertContentEqual(
+            [self.team1, self.team2], people_teams[self.team2])
+
+    def test_find_team_participations_limited_teams(self):
+        # The correct team participations are found and the query count is 1.
+        self.team1.addMember(self.no_priv, self.foo_bar)
+        self.team2.addMember(self.no_priv, self.foo_bar)
+        self.team1.addMember(self.team2, self.foo_bar, force_team_add=True)
+
+        people = [self.foo_bar, self.team2]
+        teams = [self.team1, self.team2]
+        with StormStatementRecorder() as recorder:
+            people_teams = find_team_participations(people, teams)
+        self.assertThat(recorder, HasQueryCount(Equals(1)))
+        self.assertContentEqual(
+            [self.foo_bar, self.team2], people_teams.keys())
+        self.assertContentEqual(
+            [self.team1, self.team2], people_teams[self.foo_bar])
+        self.assertContentEqual(
+            [self.team1, self.team2], people_teams[self.team2])
+
+    def test_find_team_participations_no_query(self):
+        # Check that no database query is made unless necessary.
+        people = [self.foo_bar, self.team2]
+        teams = [self.foo_bar]
+        with StormStatementRecorder() as recorder:
+            people_teams = find_team_participations(people, teams)
+        self.assertThat(recorder, HasQueryCount(Equals(0)))
+        self.assertContentEqual([self.foo_bar], people_teams.keys())
+        self.assertContentEqual([self.foo_bar], people_teams[self.foo_bar])
+
+
 class TestTeamParticipationHierarchy(TeamParticipationTestCase):
     """Participation management tests using 5 nested teams.