← Back to team overview

launchpad-reviewers team mailing list archive

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

 

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

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  #710685 Branch:+index timeouts - 3 queries triggered per linked bug
  https://bugs.launchpad.net/bugs/710685

For more details, see:
https://code.launchpad.net/~lifeless/launchpad/bug-710685/+merge/51258

Hopefully fix timeouts on some series branches that have hundreds of linked branches and were generating many hundreds of just-in-time bug queries.

This isn't a complete fix for the scaling factors involved - I know that productseries bugs and distroseries bugs are not yet addressed, but thats going to be a bit more fiddly with careful changes to bugtask searching needed.

We may be able to delete more code - DecoratedBug, for instance, may no longer be needed;; however I'm not very familiar with this part of the code base, so I want to take it slow. This branch is big enough already.

I've done the usual thing here: push as much as possible down to the DB, which already knows how to retrieve only things we can actually see; I simplified the macros to actually iterate over what we show (bugtasks - because we only showed one bugtask per bug ever).

I couldn't in the scope of this branch push the coalesce on targets down into the bugtask search. Its going to be hairy.

I've taught bugtask search how to look for bugs linked to a particular bug, and changed the code stuff for this stack to fit the model better.

Lastly I've refactored the helpers for doing query scaling tests to be even more brain dead simple.
-- 
https://code.launchpad.net/~lifeless/launchpad/bug-710685/+merge/51258
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~lifeless/launchpad/bug-710685 into lp:launchpad.
=== modified file 'lib/canonical/launchpad/testing/pages.py'
--- lib/canonical/launchpad/testing/pages.py	2011-02-17 16:38:15 +0000
+++ lib/canonical/launchpad/testing/pages.py	2011-02-25 06:42:36 +0000
@@ -679,6 +679,21 @@
     return browser
 
 
+def setupBrowserForUser(user, password='test'):
+    """Setup a browser grabbing details from a user.
+
+    :param user: The user to use.
+    :param password: The password to use.
+    """
+    naked_user = removeSecurityProxy(user)
+    email = naked_user.preferredemail.email
+    if hasattr(naked_user, '_password_cleartext_cached'):
+        password = naked_user._password_cleartext_cached
+    logout()
+    return setupBrowser(
+        auth="Basic %s:%s" % (str(email), password))
+
+
 def safe_canonical_url(*args, **kwargs):
     """Generate a bytestring URL for an object"""
     return str(canonical_url(*args, **kwargs))

=== modified file 'lib/lp/bugs/doc/bugtask-search.txt'
--- lib/lp/bugs/doc/bugtask-search.txt	2011-01-20 17:05:12 +0000
+++ lib/lp/bugs/doc/bugtask-search.txt	2011-02-25 06:42:36 +0000
@@ -1009,6 +1009,15 @@
     20 0
     21 0
 
+And we can search for bugs linked to a specific branch.
+
+    >>> search_params = BugTaskSearchParams(
+    ...     user=None, linked_branches=1)
+    >>> for task in firefox.searchTasks(search_params):
+    ...     print task.bug.id
+    4
+    5
+
 
 == Ordering search results ==
 

=== modified file 'lib/lp/bugs/model/bugtask.py'
--- lib/lp/bugs/model/bugtask.py	2011-02-23 19:22:41 +0000
+++ lib/lp/bugs/model/bugtask.py	2011-02-25 06:42:36 +0000
@@ -26,7 +26,10 @@
 from itertools import chain
 from operator import attrgetter
 
-from lazr.enum import DBItem
+from lazr.enum import (
+    DBItem,
+    Item,
+    )
 import pytz
 from sqlobject import (
     ForeignKey,
@@ -2063,20 +2066,24 @@
         if hw_clause is not None:
             extra_clauses.append(hw_clause)
 
-        if params.linked_branches == BugBranchSearch.BUGS_WITH_BRANCHES:
+        if zope_isinstance(params.linked_branches, Item):
+            if params.linked_branches == BugBranchSearch.BUGS_WITH_BRANCHES:
+                extra_clauses.append(
+                    """EXISTS (
+                        SELECT id FROM BugBranch WHERE BugBranch.bug=Bug.id)
+                    """)
+            elif params.linked_branches == BugBranchSearch.BUGS_WITHOUT_BRANCHES:
+                extra_clauses.append(
+                    """NOT EXISTS (
+                        SELECT id FROM BugBranch WHERE BugBranch.bug=Bug.id)
+                    """)
+        elif zope_isinstance(params.linked_branches, (any, all, int)):
+            # A specific search term has been supplied.
             extra_clauses.append(
                 """EXISTS (
-                    SELECT id FROM BugBranch WHERE BugBranch.bug=Bug.id)
-                """)
-        elif params.linked_branches == BugBranchSearch.BUGS_WITHOUT_BRANCHES:
-            extra_clauses.append(
-                """NOT EXISTS (
-                    SELECT id FROM BugBranch WHERE BugBranch.bug=Bug.id)
-                """)
-        else:
-            # If no branch specific search restriction is specified,
-            # we don't need to add any clause.
-            pass
+                    SELECT TRUE FROM BugBranch WHERE BugBranch.bug=Bug.id AND
+                    BugBranch.branch %s)
+                """ % search_value_to_where_condition(params.linked_branches))
 
         linked_blueprints_clause = self._buildBlueprintRelatedClause(params)
         if linked_blueprints_clause is not None:

=== modified file 'lib/lp/code/browser/branch.py'
--- lib/lp/code/browser/branch.py	2011-02-23 01:24:09 +0000
+++ lib/lp/code/browser/branch.py	2011-02-25 06:42:36 +0000
@@ -83,6 +83,7 @@
 from canonical.launchpad.browser.launchpad import Hierarchy
 from canonical.launchpad.helpers import truncate_text
 from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
+from canonical.launchpad.searchbuilder import any
 from canonical.launchpad.webapp import (
     canonical_url,
     ContextMenu,
@@ -339,10 +340,7 @@
         return Link('+register-merge', text, icon='add', enabled=enabled)
 
     def link_bug(self):
-        if self.context.linked_bugs:
-            text = 'Link to another bug report'
-        else:
-            text = 'Link to a bug report'
+        text = 'Link a bug report'
         return Link('+linkbug', text, icon='add')
 
     def link_blueprint(self):
@@ -602,14 +600,14 @@
         return len(self.landing_candidates) > 5
 
     @cachedproperty
-    def linked_bugs(self):
+    def linked_bugtasks(self):
         """Return a list of DecoratedBugs linked to the branch."""
-        bugs = self.context.linked_bugs
         if self.context.is_series_branch:
-            bugs = [
-                bug for bug in bugs
-                if bug.bugtask.status in UNRESOLVED_BUGTASK_STATUSES]
-        return bugs
+            status_filter = any(*UNRESOLVED_BUGTASK_STATUSES)
+        else:
+            status_filter = None
+        return list(self.context.getLinkedBugTasks(
+            self.user, status_filter))
 
     @cachedproperty
     def revision_info(self):

=== modified file 'lib/lp/code/browser/decorations.py'
--- lib/lp/code/browser/decorations.py	2010-08-24 10:45:57 +0000
+++ lib/lp/code/browser/decorations.py	2011-02-25 06:42:36 +0000
@@ -100,9 +100,12 @@
         tasks, we get them all at once, and provide decorated bugs (that have
         their tasks cached).
         """
+        # To whomever it may concern, this function should be pushed down to
+        # the model, and the related visibility checks made part of the query.
+        # Alternatively it may be unused at this stage.
         bugs = defaultdict(list)
-        for bug, task in self.branch.getLinkedBugsAndTasks():
-            bugs[bug].append(task)
+        for bugtask in self.branch.linked_bugs:
+            bugs[bugtask.bug].append(bugtask)
         return [DecoratedBug(bug, self.branch, tasks)
                 for bug, tasks in bugs.iteritems()
                 if check_permission('launchpad.View', bug)]

=== modified file 'lib/lp/code/browser/tests/test_bazaar.py'
--- lib/lp/code/browser/tests/test_bazaar.py	2010-10-04 19:50:45 +0000
+++ lib/lp/code/browser/tests/test_bazaar.py	2011-02-25 06:42:36 +0000
@@ -74,8 +74,3 @@
         recent_branches = self.getViewBranches('recently_imported_branches')
         self.assertEqual(branch, recent_branches[0])
         self.assertTrue(check_permission('launchpad.View', branch))
-
-
-def test_suite():
-    return unittest.TestLoader().loadTestsFromName(__name__)
-

=== modified file 'lib/lp/code/browser/tests/test_branch.py'
--- lib/lp/code/browser/tests/test_branch.py	2011-01-28 12:59:55 +0000
+++ lib/lp/code/browser/tests/test_branch.py	2011-02-25 06:42:36 +0000
@@ -19,6 +19,7 @@
 from canonical.config import config
 from canonical.database.constants import UTC_NOW
 from canonical.launchpad.helpers import truncate_text
+from canonical.launchpad.webapp.publisher import canonical_url
 from canonical.launchpad.webapp.servers import LaunchpadTestRequest
 from canonical.testing.layers import (
     DatabaseFunctionalLayer,
@@ -29,8 +30,6 @@
     find_tag_by_id,
     setupBrowser,
     )
-from canonical.launchpad.webapp.publisher import canonical_url
-
 from lp.app.interfaces.headings import IRootContext
 from lp.bugs.interfaces.bugtask import (
     BugTaskStatus,
@@ -58,6 +57,7 @@
     person_logged_in,
     TestCaseWithFactory,
     )
+from lp.testing.matchers import BrowsesWithQueryLimit
 from lp.testing.views import create_initialized_view
 
 
@@ -361,6 +361,41 @@
             self.assertTrue(
                 bug.bugtask.status in UNRESOLVED_BUGTASK_STATUSES)
 
+    def test_linked_bugs_nonseries_branch_query_scaling(self):
+        # As we add linked bugs, the query count for a branch index page stays
+        # constant.
+        branch = self.factory.makeAnyBranch()
+        browses_under_limit = BrowsesWithQueryLimit(54, branch.owner)
+        # Start with some bugs, otherwise we might see a spurious increase
+        # depending on optimisations in eager loaders.
+        with person_logged_in(branch.owner):
+            self._addBugLinks(branch)
+            self.assertThat(branch, browses_under_limit)
+        with person_logged_in(branch.owner):
+            # Add plenty of bugs.
+            for _ in range(5):
+                self._addBugLinks(branch)
+            self.assertThat(branch, browses_under_limit)
+
+    def test_linked_bugs_series_branch_query_scaling(self):
+        # As we add linked bugs, the query count for a branch index page stays
+        # constant.
+        product = self.factory.makeProduct()
+        branch = self.factory.makeProductBranch(product=product)
+        browses_under_limit = BrowsesWithQueryLimit(54, branch.owner)
+        with person_logged_in(product.owner):
+            product.development_focus.branch = branch
+        # Start with some bugs, otherwise we might see a spurious increase
+        # depending on optimisations in eager loaders.
+        with person_logged_in(branch.owner):
+            self._addBugLinks(branch)
+            self.assertThat(branch, browses_under_limit)
+        with person_logged_in(branch.owner):
+            # Add plenty of bugs.
+            for _ in range(5):
+                self._addBugLinks(branch)
+            self.assertThat(branch, browses_under_limit)
+
     def _add_revisions(self, branch, nr_revisions=1):
         revisions = []
         for seq in range(1, nr_revisions+1):
@@ -809,7 +844,3 @@
         branch = self.factory.makeProductBranch()
         root_context = IRootContext(branch)
         self.assertEqual(branch.product, root_context)
-
-
-def test_suite():
-    return unittest.TestLoader().loadTestsFromName(__name__)

=== modified file 'lib/lp/code/interfaces/branch.py'
--- lib/lp/code/interfaces/branch.py	2011-02-04 16:34:12 +0000
+++ lib/lp/code/interfaces/branch.py	2011-02-25 06:42:36 +0000
@@ -409,8 +409,16 @@
         readonly=True,
         value_type=Reference(schema=Interface))) # Really IBug
 
-    def getLinkedBugsAndTasks():
-        """Return a result set for the bugs with their tasks."""
+    def getLinkedBugTasks(user, status_filter):
+        """Return a result set for the tasks that are relevant to this branch.
+
+        When multiple tasks are on a bug, if one of the tasks is for the
+        branch.target, then only that task is returned. Otherwise the default
+        bug task is returned.
+        
+        :param user: The user doing the search.
+        :param status_filter: Passed onto the bug search as a constraint.
+        """
 
     @call_with(registrant=REQUEST_USER)
     @operation_parameters(

=== modified file 'lib/lp/code/model/branch.py'
--- lib/lp/code/model/branch.py	2011-02-23 01:24:09 +0000
+++ lib/lp/code/model/branch.py	2011-02-25 06:42:36 +0000
@@ -70,6 +70,10 @@
 from canonical.launchpad.interfaces.lpstorm import IMasterStore
 from canonical.launchpad.webapp import urlappend
 from lp.app.errors import UserCannotUnsubscribePerson
+from lp.bugs.interfaces.bugtask import (
+    IBugTaskSet,
+    BugTaskSearchParams,
+    )
 from lp.buildmaster.model.buildqueue import BuildQueue
 from lp.code.bzr import (
     BranchFormat,
@@ -302,16 +306,31 @@
         'Bug', joinColumn='branch', otherColumn='bug',
         intermediateTable='BugBranch', orderBy='id')
 
-    def getLinkedBugsAndTasks(self):
-        """Return a result set for the bugs with their tasks."""
-        from lp.bugs.model.bug import Bug
-        from lp.bugs.model.bugbranch import BugBranch
-        from lp.bugs.model.bugtask import BugTask
-        return Store.of(self).find(
-            (Bug, BugTask),
-            BugBranch.branch == self,
-            BugBranch.bug == Bug.id,
-            BugTask.bug == Bug.id)
+    def getLinkedBugTasks(self, user, status_filter):
+        """See `IBranch`."""
+        params = BugTaskSearchParams(user=user, linked_branches=self.id,
+            status=status_filter)
+        tasks = list(getUtility(IBugTaskSet).search(params))
+        # Post process to discard irrelevant tasks: we only return one task per
+        # bug, and cannot easily express this in sql (yet).
+        order = {}
+        bugtarget = self.target.context
+        # First pass calculates the order and selects the bugtasks that match
+        # our target.
+        # Second pass selects the earliest bugtask where the bug has no task on
+        # our target.
+        for task in tasks:
+            if task.bug not in order:
+                order[task.bug] = [len(order) + 1, None]
+            if task.target == bugtarget:
+                order[task.bug][1] = task
+        for task in tasks:
+            if order[task.bug][1] is None:
+                order[task.bug][1] = task
+        # Now we pull out the tasks
+        result = order.values()
+        result.sort()
+        return [task for pos, task in result]
 
     def linkBug(self, bug, registrant):
         """See `IBranch`."""

=== modified file 'lib/lp/code/templates/branch-macros.pt'
--- lib/lp/code/templates/branch-macros.pt	2011-02-10 09:52:06 +0000
+++ lib/lp/code/templates/branch-macros.pt	2011-02-25 06:42:36 +0000
@@ -101,11 +101,10 @@
   </tal:comment>
 
   <table>
-    <tal:bug-tasks repeat="bug view/linked_bugs">
-      <tr tal:condition="bug/required:launchpad.View"
-           tal:define="bugtask bug/bugtask;
-                       show_edit show_edit|nothing;"
-           tal:attributes="id string:buglink-${bug/id}"
+    <tal:bug-tasks repeat="bugtask view/linked_bugtasks">
+      <tr tal:condition="bugtask/bug/required:launchpad.View"
+           tal:define="show_edit show_edit|nothing;"
+           tal:attributes="id string:buglink-${bugtask/bug/id}"
            class="bug-branch-summary">
         <td tal:content="structure bugtask/fmt:link" class="first"/>
         <td>
@@ -121,8 +120,8 @@
         <td tal:condition="show_edit|nothing">
           <a title="Remove link"
              class="delete-buglink"
-             tal:attributes="href string:+bug/${bug/id}/+delete;
-                             id string:delete-buglink-${bug/id}">
+             tal:attributes="href string:+bug/${bugtask/bug/id}/+delete;
+                             id string:delete-buglink-${bugtask/bug/id}">
             <img src="/@@/remove" alt="Remove"/>
           </a>
         </td>

=== modified file 'lib/lp/code/templates/branch-related-bugs-specs.pt'
--- lib/lp/code/templates/branch-related-bugs-specs.pt	2011-02-23 22:32:15 +0000
+++ lib/lp/code/templates/branch-related-bugs-specs.pt	2011-02-25 06:42:36 +0000
@@ -10,18 +10,17 @@
       <tal:bugs tal:define="branch context;
                             show_edit python:True;">
 
-        <ul tal:repeat="bug view/linked_bugs">
-          <li tal:condition="bug/required:launchpad.View"
-              tal:define="bugtask bug/bugtask;
-                          show_edit show_edit|Nothing"
-              tal:attributes="id string:buglink-${bug/id}"
+        <ul tal:repeat="bugtask view/linked_bugtasks">
+          <li tal:condition="bugtask/required:launchpad.View"
+              tal:define="show_edit show_edit|Nothing"
+              tal:attributes="id string:buglink-${bugtask/bug/id}"
               class="bug-branch-summary">
             <tal:buglink content="structure bugtask/fmt:link" />
             <tal:buglink-edit condition="show_edit|nothing">
               <a title="Remove link"
                  class="delete-buglink"
-                 tal:attributes="href string:+bug/${bug/id}/+delete;
-                                 id string:delete-buglink-${bug/id}">
+                 tal:attributes="href string:+bug/${bugtask/bug/id}/+delete;
+                                 id string:delete-buglink-${bugtask/bug/id}">
                 <img src="/@@/remove" alt="Remove"/>
               </a>
             </tal:buglink-edit>

=== modified file 'lib/lp/registry/browser/tests/test_milestone.py'
--- lib/lp/registry/browser/tests/test_milestone.py	2011-02-23 19:22:41 +0000
+++ lib/lp/registry/browser/tests/test_milestone.py	2011-02-25 06:42:36 +0000
@@ -7,7 +7,7 @@
 
 from textwrap import dedent
 
-from testtools.matchers import LessThan
+from testtools.matchers import LessThan, Matcher
 from zope.component import getUtility
 
 from canonical.config import config
@@ -26,7 +26,7 @@
     TestCaseWithFactory,
     )
 from lp.testing._webservice import QueryCollector
-from lp.testing.matchers import HasQueryCount
+from lp.testing.matchers import HasQueryCount, BrowsesWithQueryLimit
 from lp.testing.memcache import MemcacheTestCase
 from lp.testing.views import create_initialized_view
 
@@ -187,19 +187,6 @@
         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):
 
@@ -246,13 +233,11 @@
     def test_milestone_eager_loading(self):
         # Verify that the number of queries does not increase with more
         # bugs with different assignees.
-        query_limit = 35
+        browses_under_limit = BrowsesWithQueryLimit(35, self.owner)
         self.add_bug(3)
-        self.assert_milestone_page_query_count(
-            self.milestone, query_limit=query_limit)
+        self.assertThat(self.milestone, browses_under_limit)
         self.add_bug(10)
-        self.assert_milestone_page_query_count(
-            self.milestone, query_limit=query_limit)
+        self.assertThat(self.milestone, browses_under_limit)
 
     def test_more_private_bugs_query_count_is_constant(self):
         # This test tests that as we add more private bugs to a milestone
@@ -370,13 +355,11 @@
         # Verify that the number of queries does not increase with more
         # bugs with different assignees as long as the number of
         # projects doesn't increase.
-        query_limit = 37
+        browses_under_limit = BrowsesWithQueryLimit(37, self.owner)
         self.add_bug(1)
-        self.assert_milestone_page_query_count(
-            self.milestone, query_limit=query_limit)
+        self.assertThat(self.milestone, browses_under_limit)
         self.add_bug(10)
-        self.assert_milestone_page_query_count(
-            self.milestone, query_limit=query_limit)
+        self.assertThat(self.milestone, browses_under_limit)
 
 
 class TestDistributionMilestoneIndexQueryCount(TestQueryCountBase):
@@ -434,10 +417,8 @@
     def test_milestone_eager_loading(self):
         # Verify that the number of queries does not increase with more
         # bugs with different assignees.
-        query_limit = 34
-        self.add_bug(3)
-        self.assert_milestone_page_query_count(
-            self.milestone, query_limit=query_limit)
+        browses_under_limit = BrowsesWithQueryLimit(34, self.owner)
+        self.add_bug(4)
+        self.assertThat(self.milestone, browses_under_limit)
         self.add_bug(10)
-        self.assert_milestone_page_query_count(
-            self.milestone, query_limit=query_limit)
+        self.assertThat(self.milestone, browses_under_limit)

=== modified file 'lib/lp/testing/__init__.py'
--- lib/lp/testing/__init__.py	2011-02-23 10:19:26 +0000
+++ lib/lp/testing/__init__.py	2011-02-25 06:42:36 +0000
@@ -594,17 +594,11 @@
             because it's stored as a hash.)
         """
         # Do the import here to avoid issues with import cycles.
-        from canonical.launchpad.testing.pages import setupBrowser
+        from canonical.launchpad.testing.pages import setupBrowserForUser
         login(ANONYMOUS)
         if user is None:
             user = self.factory.makePerson(password=password)
-        naked_user = removeSecurityProxy(user)
-        email = naked_user.preferredemail.email
-        if hasattr(naked_user, '_password_cleartext_cached'):
-            password = naked_user._password_cleartext_cached
-        logout()
-        browser = setupBrowser(
-            auth="Basic %s:%s" % (str(email), password))
+        browser = setupBrowserForUser(user, password)
         if url is not None:
             browser.open(url)
         return browser

=== modified file 'lib/lp/testing/matchers.py'
--- lib/lp/testing/matchers.py	2011-02-22 09:20:19 +0000
+++ lib/lp/testing/matchers.py	2011-02-25 06:42:36 +0000
@@ -3,6 +3,7 @@
 
 __metaclass__ = type
 __all__ = [
+    'BrowsesWithQueryLimit',
     'Contains',
     'DocTestMatches',
     'DoesNotCorrectlyProvide',
@@ -24,6 +25,7 @@
 from testtools.matchers import (
     Equals,
     DocTestMatches as OriginalDocTestMatches,
+    LessThan,
     Matcher,
     Mismatch,
     MismatchesAll,
@@ -40,7 +42,50 @@
     Proxy,
     )
 
+from canonical.launchpad.webapp import canonical_url
 from canonical.launchpad.webapp.batching import BatchNavigator
+from lp.testing._login import person_logged_in
+from lp.testing._webservice import QueryCollector
+
+
+class BrowsesWithQueryLimit(Matcher):
+    """Matches the rendering of an objects default view with a query limit.
+    
+    This is a wrapper for HasQueryCount which does the heavy lifting on the
+    query comparison - BrowsesWithQueryLimit simply provides convenient
+    glue to use a userbrowser and view an object.
+    """
+
+    def __init__(self, query_limit, user):
+        """Create a BrowsesWithQueryLimit checking for limit query_limit.
+        
+        :param query_limit: The number of queries permited for the page.
+        :param user: The user to use to render the page.
+        """    
+        self.query_limit = query_limit
+        self.user = user
+
+    def match(self, context):
+        # circular dependencies.
+        from canonical.launchpad.testing.pages import setupBrowserForUser
+        with person_logged_in(self.user):
+            context_url = canonical_url(context)
+        browser = setupBrowserForUser(self.user)
+        collector = QueryCollector()
+        collector.register()
+        try:
+            browser.open(context_url)
+            counter = HasQueryCount(LessThan(self.query_limit))
+            # When bug 724691 is fixed, this can become an AnnotateMismatch to
+            # describe the object being rendered.
+            return counter.match(collector)
+        finally:
+            # Unregister now in case this method is called multiple
+            # times in a single test.
+            collector.unregister()
+
+    def __str__(self):
+        return "BrowsesWithQueryLimit(%s, %s)" % (self.query_limit, self.user)
 
 
 class DoesNotProvide(Mismatch):

=== modified file 'lib/lp/testing/tests/test_matchers.py'
--- lib/lp/testing/tests/test_matchers.py	2010-10-19 22:28:51 +0000
+++ lib/lp/testing/tests/test_matchers.py	2011-02-25 06:42:36 +0000
@@ -17,9 +17,11 @@
 from zope.security.checker import NamesChecker
 from zope.security.proxy import ProxyFactory
 
-from lp.testing import TestCase
+from canonical.testing.layers import DatabaseFunctionalLayer
+from lp.testing import TestCase, TestCaseWithFactory
 from lp.testing._webservice import QueryCollector
 from lp.testing.matchers import (
+    BrowsesWithQueryLimit,
     Contains,
     DoesNotContain,
     DoesNotCorrectlyProvide,
@@ -226,6 +228,18 @@
             mismatch.describe())
 
 
+class TestBrowserQueryMatching(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_smoke(self):
+        person = self.factory.makePerson()
+        matcher = BrowsesWithQueryLimit(100, person)
+        self.assertThat(person, matcher)
+        matcher = Not(BrowsesWithQueryLimit(1, person))
+        self.assertThat(person, matcher)
+
+
 class DoesNotContainTests(TestCase):
 
     def test_describe(self):