Merge lp:~gmb/launchpad/garbo-hourly-nextcheck-calc-bug-545909 into lp:launchpad/db-devel

Proposed by Graham Binns
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: not available
Proposed branch: lp:~gmb/launchpad/garbo-hourly-nextcheck-calc-bug-545909
Merge into: lp:launchpad/db-devel
Diff against target: 409 lines (+236/-30)
9 files modified
database/schema/security.cfg (+2/-0)
lib/canonical/launchpad/scripts/garbo.py (+4/-25)
lib/canonical/launchpad/utilities/looptuner.py (+29/-1)
lib/lp/bugs/doc/externalbugtracker.txt (+1/-1)
lib/lp/bugs/scripts/checkwatches/__init__.py (+8/-0)
lib/lp/bugs/scripts/checkwatches/scheduler.py (+91/-0)
lib/lp/bugs/scripts/checkwatches/updater.py (+10/-0)
lib/lp/bugs/scripts/tests/test_checkwatches.py (+2/-2)
lib/lp/bugs/tests/test_bugwatch.py (+89/-1)
To merge this branch: bzr merge lp:~gmb/launchpad/garbo-hourly-nextcheck-calc-bug-545909
Reviewer Review Type Date Requested Status
Gavin Panella (community) Approve
Review via email: mp+22133@code.launchpad.net

Commit message

Add a garbo-hourly job to schedule bug watches.

Description of the change

This branch adds the garbo-hourly job for scheduling bug watches.

To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :

Review done in person. A few small tweaks needed, but otherwise jolly nice :)

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'database/schema/security.cfg'
2--- database/schema/security.cfg 2010-03-25 13:27:44 +0000
3+++ database/schema/security.cfg 2010-03-25 21:02:38 +0000
4@@ -1828,6 +1828,8 @@
5 public.bugaffectsperson = SELECT
6 public.bugnotification = SELECT, DELETE
7 public.bugnotificationrecipientarchive = SELECT
8+public.bugwatch = SELECT, UPDATE
9+public.bugwatchactivity = SELECT
10 public.codeimportresult = SELECT, DELETE
11 public.emailaddress = SELECT
12 public.oauthnonce = SELECT, DELETE
13
14=== modified file 'lib/canonical/launchpad/scripts/garbo.py'
15--- lib/canonical/launchpad/scripts/garbo.py 2010-03-05 11:46:55 +0000
16+++ lib/canonical/launchpad/scripts/garbo.py 2010-03-25 21:02:38 +0000
17@@ -13,7 +13,6 @@
18 import transaction
19 from psycopg2 import IntegrityError
20 from zope.component import getUtility
21-from zope.interface import implements
22 from storm.locals import In, SQL, Max, Min
23
24 from canonical.config import config
25@@ -27,12 +26,14 @@
26 from canonical.launchpad.interfaces import IMasterStore
27 from canonical.launchpad.interfaces.emailaddress import EmailAddressStatus
28 from canonical.launchpad.interfaces.looptuner import ITunableLoop
29-from canonical.launchpad.utilities.looptuner import DBLoopTuner
30+from canonical.launchpad.utilities.looptuner import (
31+ DBLoopTuner, TunableLoop)
32 from canonical.launchpad.webapp.interfaces import (
33 IStoreSelector, AUTH_STORE, MAIN_STORE, MASTER_FLAVOR)
34 from lp.bugs.interfaces.bug import IBugSet
35 from lp.bugs.interfaces.bugjob import ICalculateBugHeatJobSource
36 from lp.bugs.model.bugnotification import BugNotification
37+from lp.bugs.scripts.checkwatches.scheduler import BugWatchScheduler
38 from lp.code.interfaces.revision import IRevisionSet
39 from lp.code.model.branchjob import BranchJob
40 from lp.code.model.codeimportresult import CodeImportResult
41@@ -47,29 +48,6 @@
42 ONE_DAY_IN_SECONDS = 24*60*60
43
44
45-class TunableLoop:
46- implements(ITunableLoop)
47-
48- goal_seconds = 4
49- minimum_chunk_size = 1
50- maximum_chunk_size = None # Override
51- cooldown_time = 0
52-
53- def __init__(self, log, abort_time=None):
54- self.log = log
55- self.abort_time = abort_time
56-
57- def run(self):
58- assert self.maximum_chunk_size is not None, (
59- "Did not override maximum_chunk_size.")
60- DBLoopTuner(
61- self, self.goal_seconds,
62- minimum_chunk_size = self.minimum_chunk_size,
63- maximum_chunk_size = self.maximum_chunk_size,
64- cooldown_time = self.cooldown_time,
65- abort_time = self.abort_time).run()
66-
67-
68 class OAuthNoncePruner(TunableLoop):
69 """An ITunableLoop to prune old OAuthNonce records.
70
71@@ -839,6 +817,7 @@
72 OpenIDConsumerAssociationPruner,
73 RevisionCachePruner,
74 BugHeatUpdater,
75+ BugWatchScheduler,
76 ]
77 experimental_tunable_loops = []
78
79
80=== modified file 'lib/canonical/launchpad/utilities/looptuner.py'
81--- lib/canonical/launchpad/utilities/looptuner.py 2009-07-17 00:26:05 +0000
82+++ lib/canonical/launchpad/utilities/looptuner.py 2010-03-25 21:02:38 +0000
83@@ -3,7 +3,11 @@
84
85 __metaclass__ = type
86
87-__all__ = ['DBLoopTuner', 'LoopTuner']
88+__all__ = [
89+ 'DBLoopTuner',
90+ 'LoopTuner',
91+ 'TunableLoop',
92+ ]
93
94
95 from datetime import timedelta
96@@ -11,6 +15,7 @@
97
98 import transaction
99 from zope.component import getUtility
100+from zope.interface import implements
101
102 import canonical.launchpad.scripts
103 from canonical.launchpad.webapp.interfaces import (
104@@ -262,3 +267,26 @@
105 time.sleep(remaining_nap)
106 return self._time()
107
108+
109+class TunableLoop:
110+ """A base implementation of `ITunableLoop`."""
111+ implements(ITunableLoop)
112+
113+ goal_seconds = 4
114+ minimum_chunk_size = 1
115+ maximum_chunk_size = None # Override
116+ cooldown_time = 0
117+
118+ def __init__(self, log, abort_time=None):
119+ self.log = log
120+ self.abort_time = abort_time
121+
122+ def run(self):
123+ assert self.maximum_chunk_size is not None, (
124+ "Did not override maximum_chunk_size.")
125+ DBLoopTuner(
126+ self, self.goal_seconds,
127+ minimum_chunk_size = self.minimum_chunk_size,
128+ maximum_chunk_size = self.maximum_chunk_size,
129+ cooldown_time = self.cooldown_time,
130+ abort_time = self.abort_time).run()
131
132=== modified file 'lib/lp/bugs/doc/externalbugtracker.txt'
133--- lib/lp/bugs/doc/externalbugtracker.txt 2010-03-17 18:45:27 +0000
134+++ lib/lp/bugs/doc/externalbugtracker.txt 2010-03-25 21:02:38 +0000
135@@ -201,7 +201,7 @@
136
137 >>> from lp.bugs.scripts import checkwatches
138 >>> (bug_watch_updater._syncable_gnome_products ==
139- ... checkwatches.SYNCABLE_GNOME_PRODUCTS)
140+ ... checkwatches.updater.SYNCABLE_GNOME_PRODUCTS)
141 True
142
143 >>> syncable_products = ['HeartOfGold']
144
145=== added directory 'lib/lp/bugs/scripts/checkwatches'
146=== added file 'lib/lp/bugs/scripts/checkwatches/__init__.py'
147--- lib/lp/bugs/scripts/checkwatches/__init__.py 1970-01-01 00:00:00 +0000
148+++ lib/lp/bugs/scripts/checkwatches/__init__.py 2010-03-25 21:02:38 +0000
149@@ -0,0 +1,8 @@
150+# Copyright 2010 Canonical Ltd. This software is licensed under the
151+# GNU Affero General Public License version 3 (see the file LICENSE).
152+"""Top-level __init__ for the checkwatches package."""
153+
154+# We do this to maintain backwards compatibility with tests.
155+from lp.bugs.scripts.checkwatches.updater import *
156+
157+
158
159=== added file 'lib/lp/bugs/scripts/checkwatches/scheduler.py'
160--- lib/lp/bugs/scripts/checkwatches/scheduler.py 1970-01-01 00:00:00 +0000
161+++ lib/lp/bugs/scripts/checkwatches/scheduler.py 2010-03-25 21:02:38 +0000
162@@ -0,0 +1,91 @@
163+# Copyright 2010 Canonical Ltd. This software is licensed under the
164+# GNU Affero General Public License version 3 (see the file LICENSE).
165+"""Code for the BugWatch scheduler."""
166+
167+__metaclass__ = type
168+__all__ = [
169+ 'BugWatchScheduler',
170+ ]
171+
172+import transaction
173+
174+from storm.expr import Not
175+
176+from canonical.database.sqlbase import sqlvalues
177+from canonical.launchpad.utilities.looptuner import TunableLoop
178+from canonical.launchpad.interfaces import IMasterStore
179+
180+from lp.bugs.model.bugwatch import BugWatch
181+
182+
183+# The maximum additional delay in days that a watch may have placed upon
184+# it.
185+MAX_DELAY_DAYS = 6
186+# The maximum number of BugWatchActivity entries we want to examine.
187+MAX_SAMPLE_SIZE = 5
188+
189+
190+def get_delay_coefficient(max_delay_days, max_sample_size):
191+ return float(max_delay_days) / float(max_sample_size)
192+
193+
194+class BugWatchScheduler(TunableLoop):
195+ """An `ITunableLoop` for scheduling BugWatches."""
196+
197+ maximum_chunk_size = 1000
198+
199+ def __init__(self, log, abort_time=None, max_delay_days=None,
200+ max_sample_size=None):
201+ super(BugWatchScheduler, self).__init__(log, abort_time)
202+ self.transaction = transaction
203+ self.store = IMasterStore(BugWatch)
204+
205+ if max_delay_days is None:
206+ max_delay_days = MAX_DELAY_DAYS
207+ if max_sample_size is None:
208+ max_sample_size = MAX_SAMPLE_SIZE
209+ self.max_sample_size = max_sample_size
210+
211+ self.delay_coefficient = get_delay_coefficient(
212+ max_delay_days, max_sample_size)
213+
214+ def __call__(self, chunk_size):
215+ """Run the loop."""
216+ # XXX 2010-03-25 gmb bug=198767:
217+ # We cast chunk_size to an integer to ensure that we're not
218+ # trying to slice using floats or anything similarly
219+ # foolish. We shouldn't have to do this.
220+ chunk_size = int(chunk_size)
221+ query = """
222+ UPDATE BugWatch
223+ SET next_check =
224+ COALESCE(
225+ lastchecked + interval '1 day',
226+ now() AT TIME ZONE 'UTC') +
227+ (interval '1 day' * (%s * recent_failure_count))
228+ FROM (
229+ SELECT bug_watch.id,
230+ (SELECT COUNT(*)
231+ FROM (SELECT 1
232+ FROM bugwatchactivity
233+ WHERE bugwatchactivity.bug_watch = bug_watch.id
234+ AND bugwatchactivity.result IS NOT NULL
235+ ORDER BY bugwatchactivity.id DESC
236+ LIMIT %s) AS recent_failures
237+ ) AS recent_failure_count
238+ FROM BugWatch AS bug_watch
239+ WHERE bug_watch.next_check IS NULL
240+ LIMIT %s
241+ ) AS counts
242+ WHERE BugWatch.id = counts.id
243+ """ % sqlvalues(
244+ self.delay_coefficient, self.max_sample_size, chunk_size)
245+ self.transaction.begin()
246+ result = self.store.execute(query)
247+ self.log.debug("Scheduled %s watches" % result.rowcount)
248+ self.transaction.commit()
249+
250+ def isDone(self):
251+ """Return True when there are no more watches to schedule."""
252+ return self.store.find(
253+ BugWatch, BugWatch.next_check == None).is_empty()
254
255=== renamed file 'lib/lp/bugs/scripts/checkwatches.py' => 'lib/lp/bugs/scripts/checkwatches/updater.py'
256--- lib/lp/bugs/scripts/checkwatches.py 2010-03-25 16:21:10 +0000
257+++ lib/lp/bugs/scripts/checkwatches/updater.py 2010-03-25 21:02:38 +0000
258@@ -3,6 +3,16 @@
259 """Classes and logic for the checkwatches cronscript."""
260
261 __metaclass__ = type
262+__all__ = [
263+ 'BaseScheduler',
264+ 'BugWatchUpdater',
265+ 'CheckWatchesCronScript',
266+ 'CheckWatchesErrorUtility',
267+ 'externalbugtracker',
268+ 'SerialScheduler',
269+ 'TooMuchTimeSkew',
270+ 'TwistedThreadScheduler',
271+ ]
272
273 from copy import copy
274 from datetime import datetime, timedelta
275
276=== modified file 'lib/lp/bugs/scripts/tests/test_checkwatches.py'
277--- lib/lp/bugs/scripts/tests/test_checkwatches.py 2010-03-23 18:46:08 +0000
278+++ lib/lp/bugs/scripts/tests/test_checkwatches.py 2010-03-25 21:02:38 +0000
279@@ -73,8 +73,8 @@
280 # We monkey-patch externalbugtracker.get_external_bugtracker()
281 # so that it always returns what we want.
282 self.original_get_external_bug_tracker = (
283- checkwatches.externalbugtracker.get_external_bugtracker)
284- checkwatches.externalbugtracker.get_external_bugtracker = (
285+ checkwatches.updater.externalbugtracker.get_external_bugtracker)
286+ checkwatches.updater.externalbugtracker.get_external_bugtracker = (
287 always_BugzillaAPI_get_external_bugtracker)
288
289 # Create an updater with a limited set of syncable gnome
290
291=== modified file 'lib/lp/bugs/tests/test_bugwatch.py'
292--- lib/lp/bugs/tests/test_bugwatch.py 2010-01-04 11:14:16 +0000
293+++ lib/lp/bugs/tests/test_bugwatch.py 2010-03-25 21:02:38 +0000
294@@ -5,20 +5,27 @@
295
296 __metaclass__ = type
297
298+import transaction
299 import unittest
300
301+from datetime import datetime, timedelta
302+from pytz import utc
303+
304 from urlparse import urlunsplit
305
306 from zope.component import getUtility
307
308 from canonical.launchpad.ftests import login, ANONYMOUS
309+from canonical.launchpad.scripts.logger import QuietFakeLogger
310 from canonical.launchpad.webapp import urlsplit
311 from canonical.testing import (
312 DatabaseFunctionalLayer, LaunchpadFunctionalLayer, LaunchpadZopelessLayer)
313
314 from lp.bugs.interfaces.bugtracker import BugTrackerType, IBugTrackerSet
315 from lp.bugs.interfaces.bugwatch import (
316- IBugWatchSet, NoBugTrackerFound, UnrecognizedBugTrackerURL)
317+ BugWatchActivityStatus, IBugWatchSet, NoBugTrackerFound,
318+ UnrecognizedBugTrackerURL)
319+from lp.bugs.scripts.checkwatches.scheduler import BugWatchScheduler
320 from lp.registry.interfaces.person import IPersonSet
321
322 from lp.testing import TestCaseWithFactory
323@@ -372,5 +379,86 @@
324 self.bug_watch.bugtasks, list)
325
326
327+class TestBugWatchScheduler(TestCaseWithFactory):
328+ """Tests for the BugWatchScheduler, which runs as part of garbo."""
329+
330+ layer = LaunchpadFunctionalLayer
331+
332+ def setUp(self):
333+ super(TestBugWatchScheduler, self).setUp('foo.bar@canonical.com')
334+ # We'll make sure that all the other bug watches look like
335+ # they've been scheduled so that only our watch gets scheduled.
336+ for watch in getUtility(IBugWatchSet).search():
337+ watch.next_check = datetime.now(utc)
338+
339+ self.bug_watch = self.factory.makeBugWatch()
340+ transaction.commit()
341+ self.scheduler = BugWatchScheduler(QuietFakeLogger())
342+
343+ def test_scheduler_schedules_unchecked_watches(self):
344+ # The BugWatchScheduler will schedule a BugWatch that has never
345+ # been checked to be checked immediately.
346+ self.bug_watch.next_check = None
347+ self.scheduler(1)
348+
349+ self.assertNotEqual(None, self.bug_watch.next_check)
350+ self.assertTrue(
351+ self.bug_watch.next_check <= datetime.now(utc))
352+
353+ def test_scheduler_schedules_working_watches(self):
354+ # If a watch has been checked and has never failed its next
355+ # check will be scheduled for 24 hours after its last check.
356+ now = datetime.now(utc)
357+ self.bug_watch.lastchecked = now
358+ self.bug_watch.next_check = None
359+ transaction.commit()
360+ self.scheduler(1)
361+
362+ self.assertEqual(
363+ now + timedelta(hours=24), self.bug_watch.next_check)
364+
365+ def test_scheduler_schedules_failing_watches(self):
366+ # If a watch has failed once, it will be scheduled more than 24
367+ # hours after its last check.
368+ now = datetime.now(utc)
369+ self.bug_watch.lastchecked = now
370+
371+ # The delay depends on the number of failures that the watch has
372+ # had.
373+ for failure_count in range(1, 6):
374+ self.bug_watch.next_check = None
375+ self.bug_watch.addActivity(
376+ result=BugWatchActivityStatus.BUG_NOT_FOUND)
377+ transaction.commit()
378+ self.scheduler(1)
379+
380+ coefficient = self.scheduler.delay_coefficient * failure_count
381+ self.assertEqual(
382+ now + timedelta(days=1 + coefficient),
383+ self.bug_watch.next_check)
384+
385+ # The scheduler only looks at the last 5 activity items, so even
386+ # if there have been more failures the maximum delay will be 7
387+ # days.
388+ for count in range(10):
389+ self.bug_watch.addActivity(
390+ result=BugWatchActivityStatus.BUG_NOT_FOUND)
391+ self.bug_watch.next_check = None
392+ transaction.commit()
393+ self.scheduler(1)
394+ self.assertEqual(
395+ now + timedelta(days=7), self.bug_watch.next_check)
396+
397+ def test_scheduler_doesnt_schedule_scheduled_watches(self):
398+ # The scheduler will ignore watches whose next_check has been
399+ # set.
400+ next_check_date = datetime.now(utc) + timedelta(days=1)
401+ self.bug_watch.next_check = next_check_date
402+ transaction.commit()
403+ self.scheduler(1)
404+
405+ self.assertEqual(next_check_date, self.bug_watch.next_check)
406+
407+
408 def test_suite():
409 return unittest.TestLoader().loadTestsFromName(__name__)

Subscribers

People subscribed via source and target branches

to status/vote changes: