← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~allenap/launchpad/participation-keyerror-bug-810114 into lp:launchpad

 

Gavin Panella has proposed merging lp:~allenap/launchpad/participation-keyerror-bug-810114 into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #810114 in Launchpad itself: "KeyError in +participation page."
  https://bugs.launchpad.net/launchpad/+bug/810114

For more details, see:
https://code.launchpad.net/~allenap/launchpad/participation-keyerror-bug-810114/+merge/81012

This refactors the check-teamparticipation script, and addresses
performance by slurping everything from TeamMembership and
TeamParticipation into memory and doing the checks there.
-- 
https://code.launchpad.net/~allenap/launchpad/participation-keyerror-bug-810114/+merge/81012
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~allenap/launchpad/participation-keyerror-bug-810114 into lp:launchpad.
=== modified file 'cronscripts/check-teamparticipation.py'
--- cronscripts/check-teamparticipation.py	2011-09-18 03:43:46 +0000
+++ cronscripts/check-teamparticipation.py	2011-11-02 12:47:26 +0000
@@ -20,77 +20,8 @@
 
 import _pythonpath
 
-import transaction
-
-from canonical.database.sqlbase import cursor
-from lp.services.scripts.base import LaunchpadScript, LaunchpadScriptFailure
-
-
-def check_teamparticipation(log):
-    # Check self-participation.
-    query = """
-        SELECT id, name
-        FROM Person WHERE id NOT IN (
-            SELECT person FROM Teamparticipation WHERE person = team
-            ) AND merged IS NULL
-        """
-    cur = cursor()
-    cur.execute(query)
-    non_self_participants = cur.fetchall()
-    if len(non_self_participants) > 0:
-        log.warn("Some people/teams are not members of themselves: %s"
-                 % non_self_participants)
-
-    # Check if there are any circular references between teams.
-    cur.execute("""
-        SELECT tp.team, tp2.team
-        FROM teamparticipation AS tp, teamparticipation AS tp2
-        WHERE tp.team = tp2.person
-            AND tp.person = tp2.team
-            AND tp.id != tp2.id;
-        """)
-    circular_references = cur.fetchall()
-    if len(circular_references) > 0:
-        raise LaunchpadScriptFailure(
-            "Circular references found: %s" % circular_references)
-
-    # Check if there are any missing/spurious TeamParticipation entries.
-    cur.execute("SELECT id FROM Person WHERE teamowner IS NOT NULL")
-    team_ids = cur.fetchall()
-    transaction.abort()
-
-    def get_participants(team):
-        """Recurse through the team's members to get all its participants."""
-        participants = set()
-        for member in team.activemembers:
-            participants.add(member)
-            if member.is_team:
-                participants.update(get_participants(member))
-        return participants
-
-    from lp.registry.model.person import Person
-    batch = team_ids[:50]
-    team_ids = team_ids[50:]
-    while batch:
-        for [id] in batch:
-            team = Person.get(id)
-            expected = get_participants(team)
-            found = set(team.allmembers)
-            difference = expected.difference(found)
-            if len(difference) > 0:
-                people = ", ".join("%s (%s)" % (person.name, person.id)
-                                   for person in difference)
-                log.warn("%s (%s): missing TeamParticipation entries for %s."
-                         % (team.name, team.id, people))
-            reverse_difference = found.difference(expected)
-            if len(reverse_difference) > 0:
-                people = ", ".join("%s (%s)" % (person.name, person.id)
-                                   for person in reverse_difference)
-                log.warn("%s (%s): spurious TeamParticipation entries for %s."
-                         % (team.name, team.id, people))
-            transaction.abort()
-        batch = team_ids[:50]
-        team_ids = team_ids[50:]
+from lp.registry.scripts.teamparticipation import check_teamparticipation
+from lp.services.scripts.base import LaunchpadScript
 
 
 class CheckTeamParticipationScript(LaunchpadScript):
@@ -99,5 +30,6 @@
     def main(self):
         check_teamparticipation(self.logger)
 
+
 if __name__ == '__main__':
     CheckTeamParticipationScript("check-teamparticipation").run()

=== added file 'lib/lp/registry/scripts/teamparticipation.py'
--- lib/lp/registry/scripts/teamparticipation.py	1970-01-01 00:00:00 +0000
+++ lib/lp/registry/scripts/teamparticipation.py	2011-11-02 12:47:26 +0000
@@ -0,0 +1,146 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""XXX: Module docstring goes here."""
+
+__metaclass__ = type
+__all__ = [
+    "check_teamparticipation",
+    ]
+
+from collections import (
+    defaultdict,
+    namedtuple,
+    )
+from itertools import (
+    chain,
+    imap,
+    )
+
+import transaction
+from zope.component import getUtility
+
+from canonical.database.sqlbase import quote
+from canonical.launchpad.webapp.interfaces import (
+    IStoreSelector,
+    MAIN_STORE,
+    SLAVE_FLAVOR,
+    )
+from lp.registry.interfaces.teammembership import ACTIVE_STATES
+from lp.services.scripts.base import LaunchpadScriptFailure
+
+
+def get_store():
+    # Return a slave store.
+    return getUtility(IStoreSelector).get(MAIN_STORE, SLAVE_FLAVOR)
+
+
+def check_teamparticipation_self(log):
+    # Check self-participation.
+    query = """
+        SELECT id, name
+        FROM Person WHERE id NOT IN (
+            SELECT person FROM Teamparticipation WHERE person = team
+            ) AND merged IS NULL
+        """
+    non_self_participants = list(get_store().execute(query))
+    if len(non_self_participants) > 0:
+        log.warn(
+            "Some people/teams are not members of themselves: %s",
+            non_self_participants)
+
+
+def check_teamparticipation_circular(log):
+    # Check if there are any circular references between teams.
+    query = """
+        SELECT tp.team, tp2.team
+        FROM teamparticipation AS tp, teamparticipation AS tp2
+        WHERE tp.team = tp2.person
+            AND tp.person = tp2.team
+            AND tp.id != tp2.id;
+        """
+    circular_references = list(get_store().execute(query))
+    if len(circular_references) > 0:
+        raise LaunchpadScriptFailure(
+            "Circular references found: %s" % circular_references)
+
+
+ConsistencyError = namedtuple(
+    "ConsistencyError", ("type", "team", "people"))
+
+
+def check_teamparticipation_consistency(log):
+    # Check if there are any missing/spurious TeamParticipation entries.
+    store = get_store()
+
+    # Slurp everything in.
+    people = dict(
+        store.execute(
+            "SELECT id, name FROM Person"
+            " WHERE teamowner IS NULL"
+            "   AND merged IS NULL"))
+    teams = dict(
+        store.execute(
+            "SELECT id, name FROM Person"
+            " WHERE teamowner IS NOT NULL"
+            "   AND merged IS NULL"))
+    team_memberships = defaultdict(set)
+    results = store.execute(
+        "SELECT team, person FROM TeamMembership"
+        " WHERE status in %s" % quote(ACTIVE_STATES))
+    for (team, person) in results:
+        team_memberships[team].add(person)
+    team_participations = defaultdict(set)
+    results = store.execute(
+        "SELECT team, person FROM TeamParticipation")
+    for (team, person) in results:
+        team_participations[team].add(person)
+
+    # Don't hold any locks.
+    transaction.abort()
+
+    # Check team memberships.
+    def get_participants(team):
+        """Recurse through membership records to get participants."""
+        member_people = team_memberships[team].intersection(people)
+        member_people.add(team)  # Teams always participate in themselves.
+        member_teams = team_memberships[team].intersection(teams)
+        return member_people.union(
+            chain.from_iterable(imap(get_participants, member_teams)))
+
+    errors = []
+    for team in teams:
+        participants_observed = team_participations[team]
+        participants_expected = get_participants(team)
+        participants_spurious = participants_expected - participants_observed
+        participants_missing = participants_observed - participants_expected
+        if len(participants_spurious) > 0:
+            error = ConsistencyError("spurious", team, participants_spurious)
+            errors.append(error)
+        if len(participants_missing) > 0:
+            error = ConsistencyError("missing", team, participants_missing)
+            errors.append(error)
+
+    # TODO:
+    # - Check that the only participant of a *person* is the person.
+    # - Check that merged people and teams do not appear in TeamParticipation.
+
+    def get_repr(id):
+        return "%s (%d)" % (people[id] if id in people else teams[id], id)
+
+    for error in errors:
+        people_repr = ", ".join(imap(get_repr, error.people))
+        log.warn(
+            "%s: %s TeamParticipation entries for %s.",
+            get_repr(error.team), error.type, people_repr)
+
+    return errors
+
+
+def check_teamparticipation(log):
+    # Check self-participation.
+    check_teamparticipation_self(log)
+    # Check if there are any circular references between teams.
+    check_teamparticipation_circular(log)
+    # Check if there are any missing/spurious TeamParticipation entries.
+    check_teamparticipation_consistency(log)

=== modified file 'lib/lp/registry/tests/test_teammembership.py'
--- lib/lp/registry/tests/test_teammembership.py	2011-09-01 06:18:57 +0000
+++ lib/lp/registry/tests/test_teammembership.py	2011-11-02 12:47:26 +0000
@@ -9,13 +9,11 @@
     )
 import re
 import subprocess
+from unittest import TestLoader
+
+import pytz
+from testtools.content import text_content
 from testtools.matchers import Equals
-from unittest import (
-    TestCase,
-    TestLoader,
-    )
-
-import pytz
 import transaction
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
@@ -27,10 +25,6 @@
     flush_database_updates,
     sqlvalues,
     )
-from canonical.launchpad.ftests import (
-    login,
-    login_person,
-    )
 from canonical.launchpad.interfaces.lpstorm import IStore
 from canonical.launchpad.testing.systemdocs import (
     default_optionflags,
@@ -53,16 +47,22 @@
     ITeamMembershipSet,
     TeamMembershipStatus,
     )
-from lp.registry.model.teammembership import (\
+from lp.registry.model.teammembership import (
     find_team_participations,
     TeamMembership,
     TeamParticipation,
     )
+from lp.registry.scripts.teamparticipation import check_teamparticipation
+from lp.services.log.logger import BufferLogger
 from lp.testing import (
+    login,
     login_celebrity,
+    login_person,
     person_logged_in,
+    StormStatementRecorder,
+    TestCase,
     TestCaseWithFactory,
-    StormStatementRecorder)
+    )
 from lp.testing.mail_helpers import pop_notifications
 from lp.testing.matchers import HasQueryCount
 from lp.testing.storm import reload_object
@@ -1047,6 +1047,7 @@
 
 
 class TestCheckTeamParticipationScript(TestCase):
+
     layer = DatabaseFunctionalLayer
 
     def _runScript(self, expected_returncode=0):
@@ -1054,14 +1055,19 @@
             'cronscripts/check-teamparticipation.py', shell=True,
             stdin=subprocess.PIPE, stdout=subprocess.PIPE,
             stderr=subprocess.PIPE)
-        (out, err) = process.communicate()
-        self.assertEqual(process.returncode, expected_returncode, (out, err))
+        out, err = process.communicate()
+        if len(out) > 0:
+            self.addDetail("stdout", text_content(out))
+        if len(err) > 0:
+            self.addDetail("stderr", text_content(err))
+        self.assertEqual(process.returncode, expected_returncode)
         return out, err
 
     def test_no_output_if_no_invalid_entries(self):
         """No output if there's no invalid teamparticipation entries."""
         out, err = self._runScript()
-        self.assertEqual((out, err), ('', ''))
+        self.assertEqual(0, len(out))
+        self.assertEqual(0, len(err))
 
     def test_report_invalid_teamparticipation_entries(self):
         """The script reports missing/spurious TeamParticipation entries.
@@ -1103,16 +1109,13 @@
         transaction.commit()
 
         out, err = self._runScript()
-        self.assertEqual(out, '', (out, err))
-        self.failUnless(
-            re.search('missing TeamParticipation entries for zzzzz', err),
-            (out, err))
-        self.failUnless(
-            re.search('spurious TeamParticipation entries for zzzzz', err),
-            (out, err))
-        self.failUnless(
-            re.search('not members of themselves:.*zzzzz.*', err),
-            (out, err))
+        self.assertEqual(0, len(out))
+        self.failUnless(
+            re.search('missing TeamParticipation entries for zzzzz', err))
+        self.failUnless(
+            re.search('spurious TeamParticipation entries for zzzzz', err))
+        self.failUnless(
+            re.search('not members of themselves:.*zzzzz.*', err))
 
     def test_report_circular_team_references(self):
         """The script reports circular references between teams.
@@ -1145,9 +1148,34 @@
         import transaction
         transaction.commit()
         out, err = self._runScript(expected_returncode=1)
-        self.assertEqual(out, '', (out, err))
-        self.failUnless(
-            re.search('Circular references found', err), (out, err))
+        self.assertEqual(0, len(out))
+        self.failUnless(re.search('Circular references found', err))
+
+
+class TestCheckTeamParticipationScriptPerformance(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_queries(self):
+        """The script does not overly tax the database.
+
+        The whole check_teamparticipation() run executes a constant low number
+        of queries.
+        """
+        # Create a deeply nested team and member structure.
+        team = self.factory.makeTeam()
+        for num in xrange(10):
+            another_team = self.factory.makeTeam()
+            another_person = self.factory.makePerson()
+            with person_logged_in(team.teamowner):
+                team.addMember(another_team, team.teamowner)
+                team.addMember(another_person, team.teamowner)
+            team = another_team
+        transaction.commit()
+        with StormStatementRecorder() as recorder:
+            logger = BufferLogger()
+            check_teamparticipation(logger)
+        self.assertThat(recorder, HasQueryCount(Equals(6)))
 
 
 def test_suite():