Merge lp:~gary/launchpad/bug741684 into lp:launchpad

Proposed by Gary Poster
Status: Merged
Merged at revision: 12715
Proposed branch: lp:~gary/launchpad/bug741684
Merge into: lp:launchpad
Diff against target: 450 lines (+183/-67)
6 files modified
lib/lp/bugs/interfaces/bugnotification.py (+3/-3)
lib/lp/bugs/model/bug.py (+1/-1)
lib/lp/bugs/model/bugnotification.py (+73/-34)
lib/lp/bugs/scripts/bugnotification.py (+42/-14)
lib/lp/bugs/scripts/tests/test_bugnotification.py (+29/-12)
lib/lp/bugs/tests/test_bugnotification.py (+35/-3)
To merge this branch: bzr merge lp:~gary/launchpad/bug741684
Reviewer Review Type Date Requested Status
j.c.sackett (community) Approve
Review via email: mp+55656@code.launchpad.net

Description of the change

This branch makes the SQL optimizations I could find for the bug notification script, with one or two exceptions. The first exception is that I felt we could do better with how we are finding the transitive emails for teams. I make that change in another branch, lp:~gary/launchpad/bug741684-2. The other possible exception is that Robert and Stuart seem to disagree on what kind of Storm cache we have in place. In order for these optimizations to be most effective, we may need to prevent Storm cache exhaustion in an additional subsequent change of some sort.

Now I'll describe what actually *is* in this branch.

This branch focuses on two changes to the asynchronous sending of bug notifications, both of which were deployed at the last downtime deployment.

[Feature 1: quickly undone actions should not send email notifications]
  The first was to fix bug 164196: quickly undone actions should not send email notifications. In order to determine if an action is undone, the new code connected our notification records with our log records for a given change. For each collection of notifications, each notification's activity was consulted to determine what was changed, and if the end state was the same as the beginning state. This change meant that the script now looks at BugActivity records, and it did not before.

[Feature 2: emails can include notes on what structural subscription filter(s) caused the email to be sent to a recipient, to aid in organizing and highlighting email]
  I initially thought that this was the primary change in how we sent our emails. However, perhaps the bigger one from the perspective of database usage was that we looked for structural subscription filters for every notification to see if we need to tell the recipient about what filter caused them to receive the mail. This caused significant database churn in a much tighter loop.

I divide the changes below into sections that first list the file or files that changed, and then discuss the changes.

= Get filters for multiple bug notifications at once =

 * lib/lp/bugs/interfaces/bugnotification.py
 * lib/lp/bugs/model/bugnotification.py
 * lib/lp/bugs/scripts/tests/test_bugnotification.py
 * lib/lp/bugs/tests/test_bugnotification.py

Rather than getting the filters for a particular bug notification, the code now gets the filters for a set of bug notifications when possible. This reduces the number of queries within the tightest loop.

= Cache Bug.initial_message =

 * The code was getting every bug's initial_message twice, and each time was firing a SQL query. I tried cacheing it. If that causes problems, I'll get the value once in the bugnotification script code instead.

= Pre-load database values for BugNotificationSet.getNotificationsToSend =

* lib/lp/bugs/model/bugnotification.py

This change loads as much as possible of what we will need later into the Storm cache. This is therefore the change that may need tweaks of our Storm cache, as discussed above. It is also a revised version of the code that was attempted earlier as a cowboy, to no great improvement. This version may be better.

We load the direct (and smaller) dependencies immediately, simultaneously as we load the notifications; then, after we have gotten the notifications and determined what will be used, we preload the bugs and the people (using the IPersonSet method that also loads the email information that we will need later).

= Only get the filters for the "top level" recipients, not for the transitively exploded full recipient list =

 * lib/lp/bugs/scripts/bugnotification.py

I am hopeful that this will be the most important optimization. It completely eliminates the tightest loop whenever a team that does not have a preferred email is set. It then also takes advantage of the change described above in the section titled "Get filters for multiple bug notifications at once." This change should be transparent, while generating a lot less work in certain cases.

I investigated many other potential optimizations, but these were the ones I found that kept the desired behavior and actually reduced the SQL when I attempted them.

Thank you for the review.

To post a comment you must log in.
Revision history for this message
j.c.sackett (jcsackett) wrote :

Gary--

This is a clear improvement, thanks.

If we continue to see issues with scripts/bugnotification.py, I think there is still a chance that there's some churn in the for loop that repeatedly calls get_filter_descriptions (and through it, your improved getFiltersByRecipient), but you've done a lot to make that section cleaner.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/bugs/interfaces/bugnotification.py'
2--- lib/lp/bugs/interfaces/bugnotification.py 2011-02-25 16:19:58 +0000
3+++ lib/lp/bugs/interfaces/bugnotification.py 2011-03-31 00:47:42 +0000
4@@ -67,9 +67,6 @@
5 bug_filters = Attribute(
6 "List of bug filters that caused this notification.")
7
8- def getFiltersByRecipient(person):
9- """Return filters for a particular recipient."""
10-
11
12 class IBugNotificationSet(Interface):
13 """The set of bug notifications."""
14@@ -84,6 +81,9 @@
15 `BugNotificationRecipient` objects.
16 """
17
18+ def getFiltersByRecipient(notifications, person):
19+ """Return filters for a particular recipient."""
20+
21
22 class IBugNotificationRecipient(Interface):
23 """A recipient of a bug notification."""
24
25=== modified file 'lib/lp/bugs/model/bug.py'
26--- lib/lp/bugs/model/bug.py 2011-03-29 00:11:57 +0000
27+++ lib/lp/bugs/model/bug.py 2011-03-31 00:47:42 +0000
28@@ -703,7 +703,7 @@
29 days_old, getUtility(ILaunchpadCelebrities).janitor, bug=self)
30 return bugtasks.count() > 0
31
32- @property
33+ @cachedproperty
34 def initial_message(self):
35 """See `IBug`."""
36 store = Store.of(self)
37
38=== modified file 'lib/lp/bugs/model/bugnotification.py'
39--- lib/lp/bugs/model/bugnotification.py 2011-02-25 16:19:58 +0000
40+++ lib/lp/bugs/model/bugnotification.py 2011-03-31 00:47:42 +0000
41@@ -24,16 +24,23 @@
42 ForeignKey,
43 StringCol,
44 )
45+from storm.expr import (
46+ In,
47+ Join,
48+ LeftJoin,
49+ )
50 from storm.store import Store
51 from storm.locals import (
52 Int,
53 Reference,
54 )
55+from zope.component import getUtility
56 from zope.interface import implements
57
58 from canonical.config import config
59 from canonical.database.datetimecol import UtcDateTimeCol
60 from canonical.database.enumcol import EnumCol
61+from canonical.launchpad.database.message import Message
62 from canonical.database.sqlbase import (
63 SQLBase,
64 sqlvalues,
65@@ -46,8 +53,11 @@
66 IBugNotificationRecipient,
67 IBugNotificationSet,
68 )
69+from lp.bugs.model.bugactivity import BugActivity
70 from lp.bugs.model.bugsubscriptionfilter import BugSubscriptionFilter
71 from lp.bugs.model.structuralsubscription import StructuralSubscription
72+from lp.registry.interfaces.person import IPersonSet
73+from lp.registry.model.person import Person
74 from lp.registry.model.teammembership import TeamParticipation
75 from lp.services.database.stormbase import StormBase
76
77@@ -82,18 +92,6 @@
78 BugNotificationFilter.bug_subscription_filter_id),
79 BugNotificationFilter.bug_notification == self)
80
81- def getFiltersByRecipient(self, recipient):
82- """See `IBugNotification`."""
83- return IStore(BugSubscriptionFilter).find(
84- BugSubscriptionFilter,
85- (BugSubscriptionFilter.id ==
86- BugNotificationFilter.bug_subscription_filter_id),
87- BugNotificationFilter.bug_notification == self,
88- (BugSubscriptionFilter.structural_subscription_id ==
89- StructuralSubscription.id),
90- TeamParticipation.personID == recipient.id,
91- TeamParticipation.teamID == StructuralSubscription.subscriberID)
92-
93
94 class BugNotificationSet:
95 """A set of bug notifications."""
96@@ -101,36 +99,57 @@
97
98 def getNotificationsToSend(self):
99 """See IBugNotificationSet."""
100- notifications = BugNotification.select(
101- """date_emailed IS NULL""", orderBy=['bug', '-id'])
102- pending_notifications = list(notifications)
103- omitted_notifications = []
104+ # We preload the bug activity and the message in order to
105+ # try to reduce subsequent database calls: try to get direct
106+ # dependencies at once. We then also pre-load the pertinent bugs,
107+ # people (with their own dependencies), and message chunks before
108+ # returning the notifications that should be processed.
109+ # Sidestep circular reference.
110+ from lp.bugs.model.bug import Bug
111+ store = IStore(BugNotification)
112+ source = store.using(BugNotification,
113+ Join(Message,
114+ BugNotification.message==Message.id),
115+ LeftJoin(
116+ BugActivity,
117+ BugNotification.activity==BugActivity.id))
118+ results = list(source.find(
119+ (BugNotification, BugActivity, Message),
120+ BugNotification.date_emailed == None).order_by(
121+ 'BugNotification.bug', '-BugNotification.id'))
122 interval = timedelta(
123 minutes=int(config.malone.bugnotification_interval))
124 time_limit = (
125 datetime.now(pytz.timezone('UTC')) - interval)
126-
127 last_omitted_notification = None
128- for notification in pending_notifications:
129+ pending_notifications = []
130+ people_ids = set()
131+ bug_ids = set()
132+ for notification, ignore, ignore in results:
133 if notification.message.datecreated > time_limit:
134- omitted_notifications.append(notification)
135- last_omitted_notification = notification
136- elif last_omitted_notification is not None:
137- if (notification.message.owner ==
138- last_omitted_notification.message.owner and
139- notification.bug == last_omitted_notification.bug and
140- last_omitted_notification.message.datecreated -
141- notification.message.datecreated < interval):
142- omitted_notifications.append(notification)
143- last_omitted_notification = notification
144+ last_omitted_notification = notification
145+ elif (last_omitted_notification is not None and
146+ notification.message.ownerID ==
147+ last_omitted_notification.message.ownerID and
148+ notification.bugID == last_omitted_notification.bugID and
149+ last_omitted_notification.message.datecreated -
150+ notification.message.datecreated < interval):
151+ last_omitted_notification = notification
152 if last_omitted_notification != notification:
153 last_omitted_notification = None
154-
155- pending_notifications = [
156- notification
157- for notification in pending_notifications
158- if notification not in omitted_notifications
159- ]
160+ pending_notifications.append(notification)
161+ people_ids.add(notification.message.ownerID)
162+ bug_ids.add(notification.bugID)
163+ # Now we do some calls that are purely for cacheing.
164+ # Converting these into lists forces the queries to execute.
165+ if pending_notifications:
166+ cached_people = list(
167+ getUtility(IPersonSet).getPrecachedPersonsFromIDs(
168+ list(people_ids),
169+ need_validity=True,
170+ need_preferred_email=True))
171+ cached_bugs = list(
172+ IStore(Bug).find(Bug, In(Bug.id, list(bug_ids))))
173 pending_notifications.reverse()
174 return pending_notifications
175
176@@ -158,6 +177,26 @@
177 VALUES %s;""" % ', '.join(sql_values))
178 return bug_notification
179
180+ def getFiltersByRecipient(self, notifications, recipient):
181+ """See `IBugNotificationSet`."""
182+ store = IStore(BugSubscriptionFilter)
183+ source = store.using(
184+ BugSubscriptionFilter,
185+ Join(BugNotificationFilter,
186+ BugSubscriptionFilter.id ==
187+ BugNotificationFilter.bug_subscription_filter_id),
188+ Join(StructuralSubscription,
189+ BugSubscriptionFilter.structural_subscription_id ==
190+ StructuralSubscription.id),
191+ Join(TeamParticipation,
192+ TeamParticipation.teamID ==
193+ StructuralSubscription.subscriberID))
194+ return source.find(
195+ BugSubscriptionFilter,
196+ In(BugNotificationFilter.bug_notification_id,
197+ [notification.id for notification in notifications]),
198+ TeamParticipation.personID == recipient.id)
199+
200
201 class BugNotificationRecipient(SQLBase):
202 """A recipient of a bug notification."""
203
204=== modified file 'lib/lp/bugs/scripts/bugnotification.py'
205--- lib/lp/bugs/scripts/bugnotification.py 2011-03-23 15:55:44 +0000
206+++ lib/lp/bugs/scripts/bugnotification.py 2011-03-31 00:47:42 +0000
207@@ -16,6 +16,7 @@
208 from operator import itemgetter
209
210 import transaction
211+from zope.component import getUtility
212
213 from canonical.launchpad.helpers import (
214 emailPeople,
215@@ -28,6 +29,7 @@
216 get_bugmail_from_address,
217 )
218 from lp.bugs.mail.newbug import generate_bug_add_email
219+from lp.bugs.interfaces.bugnotification import IBugNotificationSet
220 from lp.services.mail.mailwrapper import MailWrapper
221
222
223@@ -107,14 +109,20 @@
224 filtered_notifications.append(notification)
225 for recipient in notification.recipients:
226 email_people = emailPeople(recipient.person)
227- if (not actor.selfgenerated_bugnotifications and
228- actor in email_people):
229- email_people.remove(actor)
230 for email_person in email_people:
231- recipients[email_person] = recipient
232+ recipients_for_person = recipients.get(email_person)
233+ if recipients_for_person is None:
234+ recipients_for_person = []
235+ recipients[email_person] = recipients_for_person
236+ recipients_for_person.append(recipient)
237 else:
238 omitted_notifications.append(notification)
239
240+ # If the actor does not want self-generated bug notifications, remove the
241+ # actor now.
242+ if not actor.selfgenerated_bugnotifications:
243+ recipients.pop(actor, None)
244+
245 if bug.duplicateof is not None:
246 text_notifications.append(
247 '*** This bug is a duplicate of bug %d ***\n %s' %
248@@ -158,18 +166,38 @@
249 bug_notification_builder = BugNotificationBuilder(bug, actor)
250 sorted_recipients = sorted(
251 recipients.items(), key=lambda t: t[0].preferredemail.email)
252- for email_person, recipient in sorted_recipients:
253+ cached_filters = {}
254+
255+ bug_notification_set = getUtility(IBugNotificationSet)
256+ def get_filter_descriptions(recipients):
257+ "Given a list of recipients, return the filter descriptions for them."
258+ descriptions = set()
259+ people_seen = set()
260+ for recipient in recipients:
261+ person = recipient.person
262+ if person in people_seen:
263+ continue
264+ people_seen.add(person)
265+ recipient_filters = cached_filters.get(person)
266+ if recipient_filters is None:
267+ recipient_filters = set(
268+ filter.description for filter
269+ in bug_notification_set.getFiltersByRecipient(
270+ filtered_notifications, person)
271+ if filter.description)
272+ cached_filters[recipient] = recipient_filters
273+ descriptions.update(recipient_filters)
274+ return descriptions
275+
276+ for email_person, recipients in sorted_recipients:
277 address = str(email_person.preferredemail.email)
278- reason = recipient.reason_body
279- rationale = recipient.reason_header
280+ # Choosing the first recipient is a bit arbitrary, but it
281+ # is simple for the user to understand. We may want to reconsider
282+ # this in the future.
283+ reason = recipients[0].reason_body
284+ rationale = recipients[0].reason_header
285
286- filters = set()
287- for notification in filtered_notifications:
288- notification_filters = notification.getFiltersByRecipient(
289- email_person)
290- for notification_filter in notification_filters:
291- if notification_filter.description is not None:
292- filters.add(notification_filter.description)
293+ filters = get_filter_descriptions(recipients)
294 if filters:
295 # There are some filters as well, add it to the email body.
296 filters_text = u"\nMatching subscriptions: %s" % ", ".join(
297
298=== modified file 'lib/lp/bugs/scripts/tests/test_bugnotification.py'
299--- lib/lp/bugs/scripts/tests/test_bugnotification.py 2011-03-23 15:55:44 +0000
300+++ lib/lp/bugs/scripts/tests/test_bugnotification.py 2011-03-31 00:47:42 +0000
301@@ -8,9 +8,10 @@
302 import unittest
303
304 import pytz
305+from storm.store import Store
306 from testtools.matchers import Not
307 from transaction import commit
308-from zope.component import getUtility
309+from zope.component import getUtility, getSiteManager
310 from zope.interface import implements
311
312 from canonical.config import config
313@@ -43,8 +44,14 @@
314 from lp.bugs.interfaces.bugnotification import IBugNotificationSet
315 from lp.bugs.interfaces.bugtask import BugTaskStatus
316 from lp.bugs.mail.bugnotificationrecipients import BugNotificationRecipients
317+from lp.bugs.model.bugnotification import (
318+ BugNotification,
319+ BugNotificationFilter,
320+# BugNotificationRecipient,
321+ )
322 from lp.bugs.model.bugtask import BugTask
323 from lp.bugs.scripts.bugnotification import (
324+ construct_email_notifications,
325 get_email_notifications,
326 get_activity_key,
327 notification_batches,
328@@ -136,9 +143,6 @@
329 self.recipients = [MockBugNotificationRecipient()]
330 self.activity = None
331
332- def getFiltersByRecipient(self, recipient):
333- return []
334-
335
336 class FakeNotification:
337 """An even simpler fake notification.
338@@ -157,6 +161,15 @@
339 self.activity = None
340
341
342+class FakeBugNotificationSetUtility:
343+ """A notification utility used for testing."""
344+
345+ implements(IBugNotificationSet)
346+
347+ def getFiltersByRecipient(self, notifications, recipient):
348+ return []
349+
350+
351 class MockBugActivity:
352 """A mock BugActivity used for testing."""
353
354@@ -244,6 +257,18 @@
355 # will abort the current transaction.
356 commit()
357
358+ sm = getSiteManager()
359+ self._original_utility = sm.getUtility(IBugNotificationSet)
360+ sm.unregisterUtility(self._original_utility)
361+ self._fake_utility = FakeBugNotificationSetUtility()
362+ sm.registerUtility(self._fake_utility)
363+
364+ def tearDown(self):
365+ sm = getSiteManager()
366+ sm.unregisterUtility(self._fake_utility)
367+ sm.registerUtility(self._original_utility)
368+
369+
370 def _getAndCheckSentNotifications(self, notifications_to_send):
371 """Return the notifications that were successfully sent.
372
373@@ -876,14 +901,6 @@
374 self.ten_minutes_ago, self.person, 'attachment',
375 item, None))
376
377-from lp.bugs.model.bugnotification import (
378- BugNotification,
379- BugNotificationFilter,
380-# BugNotificationRecipient,
381- )
382-from lp.bugs.scripts.bugnotification import construct_email_notifications
383-from storm.store import Store
384-
385
386 class TestEmailNotificationsWithFilters(TestCaseWithFactory):
387 """Ensure outgoing mails have corresponding mail headers.
388
389=== modified file 'lib/lp/bugs/tests/test_bugnotification.py'
390--- lib/lp/bugs/tests/test_bugnotification.py 2011-02-24 18:24:10 +0000
391+++ lib/lp/bugs/tests/test_bugnotification.py 2011-03-31 00:47:42 +0000
392@@ -191,7 +191,8 @@
393 # with no entries.
394 subscriber = self.factory.makePerson()
395 self.assertTrue(
396- self.notification.getFiltersByRecipient(subscriber).is_empty())
397+ BugNotificationSet().getFiltersByRecipient(
398+ [self.notification], subscriber).is_empty())
399
400 def test_getFiltersByRecipient_other_persons(self):
401 # When there is no bug filter for the recipient,
402@@ -205,7 +206,8 @@
403 bug_notification=self.notification,
404 bug_subscription_filter=bug_filter)
405 self.assertTrue(
406- self.notification.getFiltersByRecipient(recipient).is_empty())
407+ BugNotificationSet().getFiltersByRecipient(
408+ [self.notification], recipient).is_empty())
409
410 def test_getFiltersByRecipient_match(self):
411 # When there are bug filters for the recipient,
412@@ -219,7 +221,37 @@
413 bug_subscription_filter=bug_filter)
414 self.assertContentEqual(
415 [bug_filter],
416- self.notification.getFiltersByRecipient(subscriber))
417+ BugNotificationSet().getFiltersByRecipient(
418+ [self.notification], subscriber))
419+
420+ def test_getFiltersByRecipients_multiple_notifications_match(self):
421+ # When there are bug filters for the recipient for multiple
422+ # notifications, return filters for all the notifications.
423+ # Set up first notification and filter.
424+ subscriber = self.factory.makePerson()
425+ subscription = self.bug.default_bugtask.target.addSubscription(
426+ subscriber, subscriber)
427+ bug_filter = subscription.newBugFilter()
428+ BugNotificationFilter(
429+ bug_notification=self.notification,
430+ bug_subscription_filter=bug_filter)
431+ # set up second notification and filter.
432+ bug2 = self.factory.makeBug()
433+ message2 = self.factory.makeMessage()
434+ notification2 = BugNotification(
435+ message=message2, activity=None, bug=bug2, is_comment=False,
436+ date_emailed=None)
437+ subscription2 = bug2.default_bugtask.target.addSubscription(
438+ subscriber, subscriber)
439+ bug_filter2 = subscription2.newBugFilter()
440+ BugNotificationFilter(
441+ bug_notification=notification2,
442+ bug_subscription_filter=bug_filter2)
443+ # Perform the test.
444+ self.assertContentEqual(
445+ set([bug_filter, bug_filter2]),
446+ set(BugNotificationSet().getFiltersByRecipient(
447+ [self.notification, notification2], subscriber)))
448
449
450 class TestNotificationProcessingWithoutRecipients(TestCaseWithFactory):