Merge lp:~gmb/launchpad/hook-up-stored-proc-boom-bug-585803 into lp:launchpad/db-devel

Proposed by Graham Binns
Status: Merged
Approved by: Graham Binns
Approved revision: no longer in the source branch.
Merged at revision: 9414
Proposed branch: lp:~gmb/launchpad/hook-up-stored-proc-boom-bug-585803
Merge into: lp:launchpad/db-devel
Prerequisite: lp:~gmb/launchpad/stored-proc-for-bug-heat-bug-582195
Diff against target: 532 lines (+155/-198)
5 files modified
database/schema/trusted.sql (+2/-1)
lib/canonical/launchpad/scripts/garbo.py (+22/-31)
lib/lp/bugs/doc/bug-heat.txt (+114/-50)
lib/lp/bugs/model/bug.py (+17/-14)
lib/lp/bugs/tests/test_bugheat.py (+0/-102)
To merge this branch: bzr merge lp:~gmb/launchpad/hook-up-stored-proc-boom-bug-585803
Reviewer Review Type Date Requested Status
Curtis Hovey (community) rc Approve
Abel Deuring (community) code Approve
Review via email: mp+26191@code.launchpad.net

Commit message

Bug heat is now updated directly rather than relying on CalculateBugHeatJob creation.

Description of the change

This branch converts all current sites where CalculateBugHeatJobs are created to use IBug.updateHeat() or to call the calculate_bug_heat() stored procedure directly.

I've removed all CalculateBugHeatJob creation callsites (except in tests, which I'll remove in a subsequent branch along with all the CalculateBugHeatJob infrastructure) and I've updated the BugHeatUpdater garbo job to call the calculate_bug_heat() stored procedure directly rather than looping over out-of-date bugs.

I've also removed the tests that covered the callsites that I've removed.

To post a comment you must log in.
Revision history for this message
Abel Deuring (adeuring) wrote :

Hi Graham,

we discussed that it would be better to update the heat values in chunks, with initial chunk size of 1000. I like your implementation as in http://pastebin.ubuntu.com/440470/

thanks for finally fixing the heat updater!

review: Approve (code)
Revision history for this message
Curtis Hovey (sinzui) wrote :

This is good to land and you have my RC to ensure this landed even if ec2 runs slowly.

review: Approve (rc)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'database/schema/trusted.sql'
2--- database/schema/trusted.sql 2010-05-29 16:21:26 +0000
3+++ database/schema/trusted.sql 2010-05-29 16:21:27 +0000
4@@ -1696,7 +1696,8 @@
5 days_since_created = (datetime.utcnow() - date_created).days
6 max_heat = get_max_heat_for_bug(bug_id)
7 if max_heat is not None and days_since_created > 0:
8- return total_heat + (max_heat * 0.25 / days_since_created)
9+ total_heat = (
10+ total_heat + (max_heat * 0.25 / days_since_created))
11
12 return int(total_heat)
13 $$;
14
15=== added directory 'lib/canonical/launchpad/apidoc'
16=== modified file 'lib/canonical/launchpad/scripts/garbo.py'
17--- lib/canonical/launchpad/scripts/garbo.py 2010-04-14 13:14:00 +0000
18+++ lib/canonical/launchpad/scripts/garbo.py 2010-05-29 16:21:27 +0000
19@@ -13,6 +13,7 @@
20 import transaction
21 from psycopg2 import IntegrityError
22 from zope.component import getUtility
23+from zope.security.proxy import removeSecurityProxy
24 from storm.locals import In, SQL, Max, Min
25
26 from canonical.config import config
27@@ -30,6 +31,7 @@
28 from canonical.launchpad.webapp.interfaces import (
29 IStoreSelector, MAIN_STORE, MASTER_FLAVOR)
30 from lp.bugs.interfaces.bug import IBugSet
31+from lp.bugs.model.bug import Bug
32 from lp.bugs.model.bugattachment import BugAttachment
33 from lp.bugs.interfaces.bugjob import ICalculateBugHeatJobSource
34 from lp.bugs.model.bugnotification import BugNotification
35@@ -536,50 +538,39 @@
36 max_heat_age = config.calculate_bug_heat.max_heat_age
37 self.max_heat_age = max_heat_age
38
39+ self.store = IMasterStore(Bug)
40+
41+ @property
42+ def _outdated_bugs(self):
43+ outdated_bugs = getUtility(IBugSet).getBugsWithOutdatedHeat(
44+ self.max_heat_age)
45+ # We remove the security proxy so that we can access the set()
46+ # method of the result set.
47+ return removeSecurityProxy(outdated_bugs)
48+
49 def isDone(self):
50 """See `ITunableLoop`."""
51 # When the main loop has no more Bugs to process it sets
52 # offset to None. Until then, it always has a numerical
53 # value.
54- return self.is_done
55+ return self._outdated_bugs.is_empty()
56
57 def __call__(self, chunk_size):
58 """Retrieve a batch of Bugs and update their heat.
59
60 See `ITunableLoop`.
61 """
62- # XXX 2010-01-08 gmb bug=198767:
63- # We cast chunk_size to an integer to ensure that we're not
64- # trying to slice using floats or anything similarly
65- # foolish. We shouldn't have to do this.
66- chunk_size = int(chunk_size)
67- start = self.offset
68- end = self.offset + chunk_size
69+ # We multiply chunk_size by 1000 for the sake of doing updates
70+ # quickly.
71+ chunk_size = int(chunk_size * 1000)
72
73 transaction.begin()
74- bugs = getUtility(IBugSet).getBugsWithOutdatedHeat(
75- self.max_heat_age)[start:end]
76-
77- bug_count = bugs.count()
78- if bug_count > 0:
79- starting_id = bugs.first().id
80- self.log.debug(
81- "Adding CalculateBugHeatJobs for %i Bugs (starting id: %i)" %
82- (bug_count, starting_id))
83- else:
84- self.is_done = True
85-
86- self.offset = None
87- for bug in bugs:
88- # We set the starting point of the next batch to the Bug
89- # id after the one we're looking at now. If there aren't any
90- # bugs this loop will run for 0 iterations and starting_id
91- # will remain set to None.
92- start += 1
93- self.offset = start
94- self.log.debug("Adding CalculateBugHeatJob for bug %s" % bug.id)
95- getUtility(ICalculateBugHeatJobSource).create(bug)
96- self.total_processed += 1
97+ outdated_bugs = self._outdated_bugs[:chunk_size]
98+ self.log.debug("Updating heat for %s bugs" % outdated_bugs.count())
99+ outdated_bugs.set(
100+ heat=SQL('calculate_bug_heat(Bug.id)'),
101+ heat_last_updated=datetime.now(pytz.utc))
102+
103 transaction.commit()
104
105
106
107=== modified file 'lib/lp/bugs/doc/bug-heat.txt'
108--- lib/lp/bugs/doc/bug-heat.txt 2010-05-29 16:21:26 +0000
109+++ lib/lp/bugs/doc/bug-heat.txt 2010-05-29 16:21:27 +0000
110@@ -62,15 +62,11 @@
111 6
112
113
114-Adjusting bug heat in transaction
115----------------------------------
116-
117-Sometimes, when a bug changes, we want to see the changes reflected in
118-the bug's heat value immediately, without waiting for heat to be
119-recalculated. Currently we adjust heat immediately for bug privacy and
120-security.
121-
122- >>> from canonical.database.sqlbase import flush_database_updates
123+Events which trigger bug heat updates
124+-------------------------------------
125+
126+There are several events which will cause a bug's heat to be updated.
127+First, as stated above, heat will be calculated when the bug is created.
128
129 >>> bug = factory.makeBug(owner=bug_owner)
130 >>> bug.heat
131@@ -80,18 +76,116 @@
132 product against which it's filed, whose subscription is converted from
133 an indirect to a direct subscription.
134
135+Marking a bug as private also gives it an extra 150 heat points.
136+
137 >>> changed = bug.setPrivate(True, bug_owner)
138 >>> bug.heat
139 158
140
141+Setting the bug as security related adds another 250 heat points.
142+
143 >>> changed = bug.setSecurityRelated(True)
144 >>> bug.heat
145 408
146
147+Marking the bug public removes 150 heat points.
148+
149 >>> changed = bug.setPrivate(False, bug_owner)
150 >>> bug.heat
151 258
152
153+And marking it not security-related removes 250 points.
154+
155+ >>> changed = bug.setSecurityRelated(False)
156+ >>> bug.heat
157+ 8
158+
159+Adding a subscriber to the bug increases its heat by 2 points.
160+
161+ >>> new_subscriber = factory.makePerson()
162+ >>> subscription = bug.subscribe(new_subscriber, new_subscriber)
163+ >>> bug.heat
164+ 10
165+
166+When a user unsubscribes, the bug loses 2 points of heat.
167+
168+ >>> bug.unsubscribe(new_subscriber, new_subscriber)
169+ >>> bug.heat
170+ 8
171+
172+Should a user mark themselves as affected by the bug, it will gain 4
173+points of heat.
174+
175+ >>> bug.markUserAffected(new_subscriber)
176+ >>> bug.heat
177+ 12
178+
179+If a user who was previously affected marks themself as not affected,
180+the bug loses 4 points of heat.
181+
182+ >>> bug.markUserAffected(new_subscriber, False)
183+ >>> bug.heat
184+ 8
185+
186+If a user who wasn't affected by the bug marks themselve as explicitly
187+unaffected, the bug's heat doesn't change.
188+
189+ >>> unaffected_person = factory.makePerson()
190+ >>> bug.markUserAffected(unaffected_person, False)
191+ >>> bug.heat
192+ 8
193+
194+Marking the bug as a duplicate will set its heat to zero, whilst also
195+adding 10 points of heat to the bug it duplicates, 6 points for the
196+duplication and 4 points for the subscribers that the duplicated bug
197+inherits.
198+
199+ >>> duplicated_bug = factory.makeBug()
200+ >>> duplicated_bug.heat
201+ 6
202+
203+ >>> bug.markAsDuplicate(duplicated_bug)
204+ >>> bug.heat
205+ 0
206+
207+ >>> duplicated_bug.heat
208+ 16
209+
210+Unmarking the bug as a duplicate restores its heat and updates the
211+duplicated bug's heat.
212+
213+ >>> bug.markAsDuplicate(None)
214+ >>> bug.heat
215+ 8
216+
217+ >>> duplicated_bug.heat
218+ 6
219+
220+A number of other changes, handled by the Bug's addChange() method, will
221+cause heat to be recalculated, even if the heat itself may not actually
222+change.
223+
224+For example, updating the bug's description calls the addChange() event,
225+and will cause the bug's heat to be recalculated.
226+
227+We'll set the bug's heat to 0 first to demonstrate this.
228+
229+ >>> bug.setHeat(0)
230+ >>> bug.heat
231+ 0
232+
233+ >>> from datetime import datetime, timedelta
234+ >>> from pytz import timezone
235+
236+ >>> from lp.bugs.adapters.bugchange import BugDescriptionChange
237+ >>> change = BugDescriptionChange(
238+ ... when=datetime.now().replace(tzinfo=timezone('UTC')),
239+ ... person=bug.owner, what_changed='description',
240+ ... old_value=bug.description, new_value='Some text')
241+ >>> bug.addChange(change)
242+ >>> bug.heat
243+ 8
244+
245
246 Getting bugs whose heat is outdated
247 -----------------------------------
248@@ -122,8 +216,6 @@
249 getBugsWithOutdatedHeat() it will appear in the set returned by
250 getBugsWithOutdatedHeat().
251
252- >>> from datetime import datetime, timedelta
253- >>> from pytz import timezone
254 >>> old_heat_bug = factory.makeBug()
255 >>> old_heat_bug.setHeat(
256 ... 0, datetime.now(timezone('UTC')) - timedelta(days=2))
257@@ -161,52 +253,24 @@
258 >>> from canonical.launchpad.scripts.garbo import BugHeatUpdater
259 >>> from canonical.launchpad.scripts import FakeLogger
260
261+We'll commit the transaction so that the BugHeatUpdater updates the
262+right bugs.
263+
264+ >>> transaction.commit()
265 >>> update_bug_heat = BugHeatUpdater(FakeLogger(), max_heat_age=1)
266
267 BugHeatUpdater implements ITunableLoop and as such is callable. Calling
268-it as a method will add jobs to calculate the heat of for all the bugs
269-whose heat is more than seven days old.
270-
271-Before update_bug_heat is called, we'll ensure that there are no waiting
272-jobs in the bug heat calculation queue.
273-
274- >>> from lp.bugs.interfaces.bugjob import ICalculateBugHeatJobSource
275- >>> for calc_job in getUtility(ICalculateBugHeatJobSource).iterReady():
276- ... calc_job.job.start()
277- ... calc_job.job.complete()
278-
279- >>> ready_jobs = list(getUtility(ICalculateBugHeatJobSource).iterReady())
280- >>> len(ready_jobs)
281- 0
282-
283-We need to commit here to ensure that the bugs we've created are
284-available to the update_bug_heat script.
285-
286- >>> transaction.commit()
287+it as a method will recalculate the heat for all the out-of-date bugs.
288+
289+There are two bugs with heat more than a day old:
290
291 >>> getUtility(IBugSet).getBugsWithOutdatedHeat(1).count()
292 2
293
294-We need to run update_bug_heat() twice to ensure that both the bugs are
295-updated.
296-
297- >>> update_bug_heat(chunk_size=2)
298- DEBUG Adding CalculateBugHeatJobs for 2 Bugs (starting id: ...)
299- DEBUG Adding CalculateBugHeatJob for bug ...
300- DEBUG Adding CalculateBugHeatJob for bug ...
301-
302-There will now be two CalculateBugHeatJobs in the queue.
303-
304- >>> ready_jobs = list(getUtility(ICalculateBugHeatJobSource).iterReady())
305- >>> len(ready_jobs)
306- 2
307-
308-Running them will update the bugs' heat.
309-
310- >>> for calc_job in getUtility(ICalculateBugHeatJobSource).iterReady():
311- ... calc_job.job.start()
312- ... calc_job.run()
313- ... calc_job.job.complete()
314+Calling our BugHeatUpdater will update the heat of those bugs.
315+
316+ >>> update_bug_heat(chunk_size=1)
317+ DEBUG Updating heat for 2 bugs
318
319 IBugSet.getBugsWithOutdatedHeat() will now return an empty set since all
320 the bugs have been updated.
321
322=== modified file 'lib/lp/bugs/model/bug.py'
323--- lib/lp/bugs/model/bug.py 2010-05-29 16:21:26 +0000
324+++ lib/lp/bugs/model/bug.py 2010-05-29 16:21:27 +0000
325@@ -73,7 +73,6 @@
326 from lp.bugs.interfaces.bugactivity import IBugActivitySet
327 from lp.bugs.interfaces.bugattachment import (
328 BugAttachmentType, IBugAttachmentSet)
329-from lp.bugs.interfaces.bugjob import ICalculateBugHeatJobSource
330 from lp.bugs.interfaces.bugmessage import IBugMessageSet
331 from lp.bugs.interfaces.bugnomination import (
332 NominationError, NominationSeriesObsoleteError)
333@@ -490,8 +489,6 @@
334 sub = BugSubscription(
335 bug=self, person=person, subscribed_by=subscribed_by)
336
337- getUtility(ICalculateBugHeatJobSource).create(self)
338-
339 # Ensure that the subscription has been flushed.
340 Store.of(sub).flush()
341
342@@ -501,6 +498,7 @@
343 if suppress_notify is False:
344 notify(ObjectCreatedEvent(sub, user=subscribed_by))
345
346+ self.updateHeat()
347 return sub
348
349 def unsubscribe(self, person, unsubscribed_by):
350@@ -525,6 +523,7 @@
351 # flushed so that code running with implicit flushes
352 # disabled see the change.
353 store.flush()
354+ self.updateHeat()
355 return
356
357 def unsubscribeFromDupes(self, person, unsubscribed_by):
358@@ -803,7 +802,7 @@
359 notification_data['text'], change.person, recipients,
360 when)
361
362- getUtility(ICalculateBugHeatJobSource).create(self)
363+ self.updateHeat()
364
365 def expireNotifications(self):
366 """See `IBug`."""
367@@ -1459,7 +1458,7 @@
368 if dupe._getAffectedUser(user) is not None:
369 dupe.markUserAffected(user, affected)
370
371- getUtility(ICalculateBugHeatJobSource).create(self)
372+ self.updateHeat()
373
374 @property
375 def readonly_duplicateof(self):
376@@ -1470,6 +1469,7 @@
377 """See `IBug`."""
378 field = DuplicateBug()
379 field.context = self
380+ current_duplicateof = self.duplicateof
381 try:
382 if duplicate_of is not None:
383 field._validate(duplicate_of)
384@@ -1478,15 +1478,17 @@
385 raise InvalidDuplicateValue(validation_error)
386
387 if duplicate_of is not None:
388- # Create a job to update the heat of the master bug and set
389- # this bug's heat to 0 (since it's a duplicate, it shouldn't
390- # have any heat at all).
391- getUtility(ICalculateBugHeatJobSource).create(duplicate_of)
392+ # Update the heat of the master bug and set this bug's heat
393+ # to 0 (since it's a duplicate, it shouldn't have any heat
394+ # at all).
395+ self.setHeat(0)
396+ duplicate_of.updateHeat()
397+ else:
398+ # Otherwise, recalculate this bug's heat, since it will be 0
399+ # from having been a duplicate. We also update the bug that
400+ # was previously duplicated.
401 self.updateHeat()
402- else:
403- # Otherwise, create a job to recalculate this bug's heat,
404- # since it will be 0 from having been a duplicate.
405- getUtility(ICalculateBugHeatJobSource).create(self)
406+ current_duplicateof.updateHeat()
407
408 def setCommentVisibility(self, user, comment_number, visible):
409 """See `IBug`."""
410@@ -1863,7 +1865,8 @@
411 Bug.heat_last_updated < last_updated_cutoff,
412 Bug.heat_last_updated == None)
413
414- return store.find(Bug, last_updated_clause).order_by('id')
415+ return store.find(
416+ Bug, Bug.duplicateof==None, last_updated_clause).order_by('id')
417
418
419 class BugAffectsPerson(SQLBase):
420
421=== modified file 'lib/lp/bugs/tests/test_bugheat.py'
422--- lib/lp/bugs/tests/test_bugheat.py 2010-04-01 03:09:43 +0000
423+++ lib/lp/bugs/tests/test_bugheat.py 2010-05-29 16:21:27 +0000
424@@ -112,108 +112,6 @@
425 self.assertIn(('bug_job_id', job.context.id), vars)
426 self.assertIn(('bug_job_type', job.context.job_type.title), vars)
427
428- def test_bug_changes_adds_job(self):
429- # Calling addChange() on a Bug will add a CalculateBugHeatJob
430- # for that bug to the queue.
431- self.assertEqual(0, self._getJobCount())
432-
433- change = BugDescriptionChange(
434- when=datetime.now().replace(tzinfo=pytz.timezone('UTC')),
435- person=self.bug.owner, what_changed='description',
436- old_value=self.bug.description, new_value='Some text')
437- self.bug.addChange(change)
438-
439- # There will now be a job in the queue.
440- self.assertEqual(1, self._getJobCount())
441-
442- def test_subscribing_adds_job(self):
443- # Calling Bug.subscribe() will add a CalculateBugHeatJob for the
444- # Bug.
445- self.assertEqual(0, self._getJobCount())
446-
447- person = self.factory.makePerson()
448- self.bug.subscribe(person, person)
449- transaction.commit()
450-
451- # There will now be a job in the queue.
452- self.assertEqual(1, self._getJobCount())
453-
454- def test_unsubscribing_adds_job(self):
455- # Calling Bug.unsubscribe() will add a CalculateBugHeatJob for the
456- # Bug.
457- self.assertEqual(0, self._getJobCount())
458-
459- self.bug.unsubscribe(self.bug.owner, self.bug.owner)
460- transaction.commit()
461-
462- # There will now be a job in the queue.
463- self.assertEqual(1, self._getJobCount())
464-
465- def test_marking_affected_adds_job(self):
466- # Marking a user as affected by a bug adds a CalculateBugHeatJob
467- # for the bug.
468- self.assertEqual(0, self._getJobCount())
469-
470- person = self.factory.makePerson()
471- self.bug.markUserAffected(person)
472-
473- # There will now be a job in the queue.
474- self.assertEqual(1, self._getJobCount())
475-
476- def test_marking_unaffected_adds_job(self):
477- # Marking a user as unaffected by a bug adds a CalculateBugHeatJob
478- # for the bug.
479- self.assertEqual(0, self._getJobCount())
480-
481- self.bug.markUserAffected(self.bug.owner, False)
482-
483- # There will now be a job in the queue.
484- self.assertEqual(1, self._getJobCount())
485-
486- def test_bug_creation_creates_job(self):
487- # Creating a bug adds a CalculateBugHeatJob for the new bug.
488- self.assertEqual(0, self._getJobCount())
489-
490- new_bug = self.factory.makeBug()
491-
492- # There will now be a job in the queue.
493- self.assertEqual(1, self._getJobCount())
494-
495- def test_marking_dupe_creates_job(self):
496- # Marking a bug as a duplicate of another bug creates a job to
497- # update the master bug.
498- new_bug = self.factory.makeBug()
499- new_bug.setHeat(42)
500- self._completeJobsAndAssertQueueEmpty()
501-
502- new_bug.markAsDuplicate(self.bug)
503-
504- # There will now be a job in the queue.
505- self.assertEqual(1, self._getJobCount())
506-
507- # And the job will be for the master bug.
508- bug_job = self._getJobs()[0]
509- self.assertEqual(bug_job.bug, self.bug)
510-
511- # Also, the duplicate bug's heat will have been set to zero.
512- self.assertEqual(0, new_bug.heat)
513-
514- def test_unmarking_dupe_creates_job(self):
515- # Unmarking a bug as a duplicate will create a
516- # CalculateBugHeatJob for the bug, since its heat will be 0 from
517- # having been marked as a duplicate.
518- new_bug = self.factory.makeBug()
519- new_bug.markAsDuplicate(self.bug)
520- self._completeJobsAndAssertQueueEmpty()
521- new_bug.markAsDuplicate(None)
522-
523- # There will now be a job in the queue.
524- self.assertEqual(1, self._getJobCount())
525-
526- # And the job will be for the master bug.
527- bug_job = self._getJobs()[0]
528- self.assertEqual(bug_job.bug, new_bug)
529-
530
531 class MaxHeatByTargetBase:
532 """Base class for testing a bug target's max_bug_heat attribute."""

Subscribers

People subscribed via source and target branches

to status/vote changes: