← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~sinzui/launchpad/nominations-investigation-4 into lp:launchpad

 

Curtis Hovey has proposed merging lp:~sinzui/launchpad/nominations-investigation-4 into lp:launchpad.

Commit message:
Cache the recipients when making repeated calls to send bug notifications.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #874250 in Launchpad itself: "BugNomination:+editstatus timeout for bugs with many tasks"
  https://bugs.launchpad.net/launchpad/+bug/874250

For more details, see:
https://code.launchpad.net/~sinzui/launchpad/nominations-investigation-4/+merge/129990

Bug.getBugNotificationRecipients() may be called multiple times when
creating notifications for multiple changes or bugs in many
relationships. In general, the method can only return three different
sets for most changes, so the queries are often repeated.

This change may not make bug nomination approval fast enough to
not timeout, but it will make Lp and the test suite faster in
general.

--------------------------------------------------------------------

RULES

    Pre-implementation: wgrant, wallyworld
    * Update bug.getBugNotificationRecipients to cache the RecipientSet
      for the BugNotificationLevel that was passed to the method.
    * Return a copy of the cached RecipientSet because many callsites
      mutate the set.
    * Ensure that actions that change which structural subscribers
      are selected also clear the cache.


QA

    * Clear the staging inbox.
    * Ask webops to make you a member of
      https://qastaging.launchpad.net/~ubuntu-release
    * Visit https://bugs.qastaging.launchpad.net/gdp/+bug/939614
      and use the +editstaus form to change a task's milestone to 0.5.12.
    * Verify a notification was sent to Curtis.
    * Visit https://bugs.qastaging.launchpad.net/gdp/+bug/939614
      and use the +editstaus form to change a task's milestone to 0.5.13.
    * Verify a notification was sent to Caroline.
    * Visit https://bugs.qastaging.launchpad.net/gdp/+bug/939614
      and use the +editstaus form to change the project to pocket-lint.
    * Verify a notification was sent to the me-9 team (check headers).
    * Visit https://bugs.qastaging.launchpad.net/ubuntu/+source/obexd/+bug/872044
    * Verify the page does not timeout...it will timeout trying to load the
      first time so if this works, the escalated bug could be fixed.


LINT

    lib/lp/bugs/doc/bugsubscription.txt
    lib/lp/bugs/interfaces/bug.py
    lib/lp/bugs/model/bug.py
    lib/lp/bugs/model/bugtask.py
    lib/lp/bugs/model/tests/test_bugtask.py
    lib/lp/bugs/tests/test_bug_notification_recipients.py
    lib/lp/bugs/tests/test_bugchanges.py


Loc

    This branch adds about 150 lines, but I have a credit of 4,900 lines
    as of this week. If you insist, I can gut doc/bugsubscription.txt
    because I suspect it duplicates unit tests.


TEST

    ./bin/test -vvc -t cache lp.bugs.tests.test_bug_notification_recipients
    ./bin/test -vvc -t cached lp.bugs.model.tests.test_bugtask
    ./bin/test -vvc \
        -t doc/bugnotificationrecipients.txt -t doc/bugsubscription.txt \
        -t test_bug_notification_recipients -t bugs-emailinterface.txt \
        -t test_bugchanges lp.bugs


IMPLEMENTATION

I changed getBugNotificationRecipients to be a private method that does
the real work. I added a replacement method that differs to
cachedproperties and returns a new BugNotificationRecipients set. I
added a method to clear the caches.
    lib/lp/bugs/interfaces/bug.py
    lib/lp/bugs/model/bug.py
    lib/lp/bugs/tests/test_bug_notification_recipients.py

I updated the transition methods that change the affected structures to
clear the caches.
    lib/lp/bugs/model/bugtask.py
    lib/lp/bugs/model/tests/test_bugtask.py


I updated tests confused by the cache changes.
    lib/lp/bugs/doc/bugsubscription.txt
    lib/lp/bugs/tests/test_bugchanges.py
-- 
https://code.launchpad.net/~sinzui/launchpad/nominations-investigation-4/+merge/129990
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~sinzui/launchpad/nominations-investigation-4 into lp:launchpad.
=== modified file 'lib/lp/bugs/doc/bugsubscription.txt'
--- lib/lp/bugs/doc/bugsubscription.txt	2012-09-17 16:13:40 +0000
+++ lib/lp/bugs/doc/bugsubscription.txt	2012-10-16 21:28:23 +0000
@@ -748,6 +748,7 @@
     ...  for subscription in new_bug.subscriptions]
     [u'Foo Bar']
 
+    >>> new_bug.clearBugNotificationRecipientsCache()
     >>> getSubscribers(new_bug)
     ['foo.bar@xxxxxxxxxxxxx', 'support@xxxxxxxxxx']
 

=== modified file 'lib/lp/bugs/interfaces/bug.py'
--- lib/lp/bugs/interfaces/bug.py	2012-10-15 20:52:21 +0000
+++ lib/lp/bugs/interfaces/bug.py	2012-10-16 21:28:23 +0000
@@ -488,6 +488,14 @@
         details of this implementation.
         """
 
+    def clearBugNotificationRecipientsCache():
+        """Clear the bug notification recipient BugNotificationLevel cache.
+
+        Call this when a change to a bug or bugtask would change the
+        notification recipients. Changing a a bugtask's milestone or
+        target is such a case.
+        """
+
     def canBeAQuestion():
         """Return True of False if a question can be created from this bug.
 

=== modified file 'lib/lp/bugs/model/bug.py'
--- lib/lp/bugs/model/bug.py	2012-10-15 20:52:21 +0000
+++ lib/lp/bugs/model/bug.py	2012-10-16 21:28:23 +0000
@@ -1085,15 +1085,53 @@
         """
         return get_also_notified_subscribers(self, recipients, level)
 
-    def getBugNotificationRecipients(self,
-                                     level=BugNotificationLevel.LIFECYCLE):
-        """See `IBug`."""
+    def _getBugNotificationRecipients(self, level):
+        """Get the recipients for the BugNotificationLevel."""
         recipients = BugNotificationRecipients()
         self.getDirectSubscribers(
             recipients, level=level, filter_visible=True)
         self.getIndirectSubscribers(recipients, level=level)
         return recipients
 
+    @cachedproperty
+    def _notification_recipients_for_lifecycle(self):
+        """The cached BugNotificationRecipients for LIFECYCLE events."""
+        return self._getBugNotificationRecipients(
+            BugNotificationLevel.LIFECYCLE)
+
+    @cachedproperty
+    def _notification_recipients_for_metadata(self):
+        """The cached BugNotificationRecipients for METADATA events."""
+        return self._getBugNotificationRecipients(
+            BugNotificationLevel.METADATA)
+
+    @cachedproperty
+    def _notification_recipients_for_comments(self):
+        """The cached BugNotificationRecipients for COMMENT events."""
+        return self._getBugNotificationRecipients(
+            BugNotificationLevel.COMMENTS)
+
+    def getBugNotificationRecipients(self,
+                                     level=BugNotificationLevel.LIFECYCLE):
+        """See `IBug`."""
+        recipients = BugNotificationRecipients()
+        if level == BugNotificationLevel.LIFECYCLE:
+            recipients.update(self._notification_recipients_for_lifecycle)
+        elif level == BugNotificationLevel.METADATA:
+            recipients.update(self._notification_recipients_for_metadata)
+        else:
+            recipients.update(self._notification_recipients_for_comments)
+        return recipients
+
+    def clearBugNotificationRecipientsCache(self):
+        cache = get_property_cache(self)
+        if getattr(cache, '_notification_recipients_for_lifecycle', False):
+            del cache._notification_recipients_for_lifecycle
+        if getattr(cache, '_notification_recipients_for_metadata', False):
+            del cache._notification_recipients_for_metadata
+        if getattr(cache, '_notification_recipients_for_comments', False):
+            del cache._notification_recipients_for_comments
+
     def addCommentNotification(self, message, recipients=None, activity=None):
         """See `IBug`."""
         if recipients is None:

=== modified file 'lib/lp/bugs/model/bugtask.py'
--- lib/lp/bugs/model/bugtask.py	2012-10-15 02:32:30 +0000
+++ lib/lp/bugs/model/bugtask.py	2012-10-16 21:28:23 +0000
@@ -772,8 +772,10 @@
             raise UserCannotEditBugTaskMilestone(
                 "User does not have sufficient permissions "
                 "to edit the bug task milestone.")
-        else:
-            self.milestone = new_milestone
+        self.milestone = new_milestone
+        # Clear the recipient caches so that the milestone subscribers are
+        # notified.
+        self.bug.clearBugNotificationRecipientsCache()
 
     def transitionToImportance(self, new_importance, user):
         """See `IBugTask`."""
@@ -1141,6 +1143,7 @@
         # As a result of the transition, some subscribers may no longer
         # have access to the parent bug. We need to run a job to remove any
         # such subscriptions.
+        self.bug.clearBugNotificationRecipientsCache()
         getUtility(IRemoveArtifactSubscriptionsJobSource).create(
             user, [self.bug], pillar=target_before_change.pillar)
 

=== modified file 'lib/lp/bugs/model/tests/test_bugtask.py'
--- lib/lp/bugs/model/tests/test_bugtask.py	2012-09-27 14:25:32 +0000
+++ lib/lp/bugs/model/tests/test_bugtask.py	2012-10-16 21:28:23 +0000
@@ -86,6 +86,7 @@
 from lp.services.features.testing import FeatureFixture
 from lp.services.job.tests import block_on_job
 from lp.services.log.logger import FakeLogger
+from lp.services.propertycache import get_property_cache
 from lp.services.searchbuilder import any
 from lp.services.webapp.authorization import check_permission
 from lp.services.webapp.interfaces import ILaunchBag
@@ -2634,6 +2635,20 @@
             new_product.bugtargetdisplayname,
             removeSecurityProxy(task).targetnamecache)
 
+    def test_cached_recipients_cleared(self):
+        # The bug's notification recipients caches are cleared when
+        # transitionToTarget() is called.
+        new_product = self.factory.makeProduct()
+        task = self.factory.makeBugTask()
+        # The factory caused COMMENT notifications which filled the bug cache.
+        cache = get_property_cache(task.bug)
+        self.assertIsNotNone(
+            getattr(cache, '_notification_recipients_for_comments', None))
+        with person_logged_in(task.owner):
+            task.transitionToTarget(new_product, task.owner)
+        self.assertIsNone(
+            getattr(cache, '_notification_recipients_for_comments', None))
+
     def test_accesspolicyartifacts_updated(self):
         # transitionToTarget updates the AccessPolicyArtifacts related
         # to the bug.
@@ -2673,6 +2688,27 @@
             [sp, sp.distribution_sourcepackage, other_distro])
 
 
+class TransitionToMilestoneTestCase(TestCaseWithFactory):
+    """Tests for BugTask.transitionToMilestone."""
+
+    layer = DatabaseFunctionalLayer
+
+    def test_cached_recipients_cleared(self):
+        # The bug's notification recipients caches are cleared when
+        # transitionToMilestone() is called.
+        task = self.factory.makeBugTask()
+        product = task.target
+        milestone = self.factory.makeMilestone(product=product)
+        # The factory caused COMMENT notifications which filled the bug cache.
+        cache = get_property_cache(task.bug)
+        self.assertIsNotNone(
+            getattr(cache, '_notification_recipients_for_comments', None))
+        with person_logged_in(task.target.owner):
+            task.transitionToMilestone(milestone, task.target.owner)
+        self.assertIsNone(
+            getattr(cache, '_notification_recipients_for_comments', None))
+
+
 class TestTransitionsRemovesSubscribersJob(TestCaseWithFactory):
     """Test that various bug transitions invoke RemoveArtifactSubscribers
     job."""

=== modified file 'lib/lp/bugs/tests/test_bug_notification_recipients.py'
--- lib/lp/bugs/tests/test_bug_notification_recipients.py	2012-09-18 18:36:09 +0000
+++ lib/lp/bugs/tests/test_bug_notification_recipients.py	2012-10-16 21:28:23 +0000
@@ -6,17 +6,25 @@
 __metaclass__ = type
 
 from zope.component import getUtility
+from testtools.matchers import (
+    Equals,
+    GreaterThan,
+    )
 
 from lp.app.enums import InformationType
+from lp.bugs.enums import BugNotificationLevel
 from lp.registry.interfaces.accesspolicy import (
     IAccessArtifactGrantSource,
     IAccessPolicySource,
     )
+from lp.services.propertycache import get_property_cache
 from lp.testing import (
     person_logged_in,
+    StormStatementRecorder,
     TestCaseWithFactory,
     )
 from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.matchers import HasQueryCount
 
 
 class TestBugNotificationRecipients(TestCaseWithFactory):
@@ -185,3 +193,57 @@
             dupe.markAsDuplicate(bug)
             self.assertContentEqual(
                 [owner, subscriber], bug.getBugNotificationRecipients())
+
+    def test_cache_by_bug_notification_level(self):
+        # The BugNotificationRecipients set is cached by notification level
+        # to avoid duplicate work. The returned set is copy of the cached set.
+        subscriber = self.factory.makePerson()
+        product = self.factory.makeProduct()
+        with person_logged_in(subscriber):
+            subscription = product.addBugSubscription(subscriber, subscriber)
+            bug_filter = subscription.bug_filters[0]
+            bug_filter.bug_notification_level = BugNotificationLevel.COMMENTS
+        bug = self.factory.makeBug(target=product)
+        # The factory call queued LIFECYCLE and COMMENT notifications.
+        bug.clearBugNotificationRecipientsCache()
+        levels = [
+            BugNotificationLevel.LIFECYCLE,
+            BugNotificationLevel.METADATA,
+            BugNotificationLevel.COMMENTS,
+            ]
+        for level in levels:
+            with StormStatementRecorder() as recorder:
+                first_recipients = bug.getBugNotificationRecipients(
+                    level=level)
+                self.assertThat(recorder, HasQueryCount(GreaterThan(1)))
+            with StormStatementRecorder() as recorder:
+                second_recipients = bug.getBugNotificationRecipients(
+                    level=level)
+                self.assertThat(recorder, HasQueryCount(Equals(0)))
+            self.assertContentEqual([bug.owner, subscriber], first_recipients)
+            self.assertContentEqual(first_recipients, second_recipients)
+            self.assertIsNot(first_recipients, second_recipients)
+
+    def test_clearBugNotificationRecipientCache(self):
+        subscriber = self.factory.makePerson()
+        product = self.factory.makeProduct()
+        with person_logged_in(subscriber):
+            subscription = product.addBugSubscription(subscriber, subscriber)
+            bug_filter = subscription.bug_filters[0]
+            bug_filter.bug_notification_level = BugNotificationLevel.COMMENTS
+        bug = self.factory.makeBug(target=product)
+        levels = [
+            BugNotificationLevel.LIFECYCLE,
+            BugNotificationLevel.METADATA,
+            BugNotificationLevel.COMMENTS,
+            ]
+        for level in levels:
+            bug.getBugNotificationRecipients(level=level)
+        bug.clearBugNotificationRecipientsCache()
+        cache = get_property_cache(bug)
+        self.assertIsNone(
+            getattr(cache, '_notification_recipients_for_lifecycle', None))
+        self.assertIsNone(
+            getattr(cache, '_notification_recipients_for_metadata', None))
+        self.assertIsNone(
+            getattr(cache, '_notification_recipients_for_comments', None))

=== modified file 'lib/lp/bugs/tests/test_bugchanges.py'
--- lib/lp/bugs/tests/test_bugchanges.py	2012-10-12 03:11:38 +0000
+++ lib/lp/bugs/tests/test_bugchanges.py	2012-10-16 21:28:23 +0000
@@ -95,6 +95,7 @@
         else:
             self.old_activities = old_activities
             self.old_notification_ids = old_notification_ids
+        bug.clearBugNotificationRecipientsCache()
 
     def changeAttribute(self, obj, attribute, new_value):
         """Set the value of `attribute` on `obj` to `new_value`.


Follow ups