Merge ~vorlon/britney/+git/britney2-ubuntu:futz-with-email-frequency into ~ubuntu-release/britney/+git/britney2-ubuntu:master

Proposed by Steve Langasek
Status: Merged
Merged at revision: 132ffaf4653523ca5f05e1ffbc64ed61d48c3f1f
Proposed branch: ~vorlon/britney/+git/britney2-ubuntu:futz-with-email-frequency
Merge into: ~ubuntu-release/britney/+git/britney2-ubuntu:master
Diff against target: 204 lines (+82/-27)
2 files modified
britney2/policies/email.py (+55/-24)
tests/test_email.py (+27/-3)
Reviewer Review Type Date Requested Status
Iain Lane Approve
Adam Conrad Pending
Review via email: mp+326556@code.launchpad.net

Description of the change

Refinement of the email handling about stuck packages.

Packages that are not valid candidates but also not temporarily rejected by policy should be notified about sooner than 3 days. Packages that are valid candidates but stuck because of update_output should be notified about later. Implement this more nuanced policy, per LP: #1671468.

Supersedes https://code.launchpad.net/~robru/britney/+git/britney2-ubuntu/+merge/320837

To post a comment you must log in.
Revision history for this message
Iain Lane (laney) wrote :

I originally had an objection to sending email after only 1 day of a package being stuck.

Consider packages that take longer than a day to build - these will be invalid (out of date), so you'll mail before they are ready AFAICS. I don't think proposed-migration can actually tell if a package is out of date because it is still building or because it's failed to build, either, so as it stands I don't think this is fixable. That's why we currently wait at least 3 days before mailing.

On reflection, though, I think I'm okay with merging this change and we'll see if that is a problem in reality.

Would you please comment the code where you calculate last_due? That is quite difficult to reason about and it would benefit from some explanation up front. You can just push that directly yourself.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/britney2/policies/email.py b/britney2/policies/email.py
2index a453624..5be71d2 100644
3--- a/britney2/policies/email.py
4+++ b/britney2/policies/email.py
5@@ -1,6 +1,7 @@
6 import os
7 import re
8 import json
9+import math
10 import socket
11 import smtplib
12
13@@ -12,7 +13,7 @@ from britney2.policies.policy import BasePolicy, PolicyVerdict
14
15
16 # Recurring emails should never be more than this many days apart
17-MAX_FREQUENCY = 30
18+MAX_INTERVAL = 30
19
20 API_PREFIX = 'https://api.launchpad.net/1.0/'
21 USER = API_PREFIX + '~'
22@@ -28,13 +29,13 @@ BOTS = {
23 MESSAGE = """From: Ubuntu Release Team <noreply@canonical.com>
24 To: {recipients}
25 X-Proposed-Migration: notice
26-Subject: [proposed-migration] {source_name} {version} stuck in {series}-proposed for {rounded_age} days.
27+Subject: [proposed-migration] {source_name} {version} stuck in {series}-proposed for {age} day{plural}.
28
29 Hi,
30
31 {source_name} {version} needs attention.
32
33-It has been stuck in {series}-proposed for {rounded_age} days.
34+It has been stuck in {series}-proposed for {age} day{plural}.
35
36 You either sponsored or uploaded this package, please investigate why it hasn't been approved for migration.
37
38@@ -102,6 +103,14 @@ class EmailPolicy(BasePolicy, Rest):
39 with open(self.filename, encoding='utf-8') as data:
40 self.cache = json.load(data)
41 self.log("Loaded cached email data from %s" % self.filename)
42+ tmp = self.filename + '.new'
43+ if os.path.exists(tmp):
44+ # if we find a record on disk of emails sent from an incomplete
45+ # britney run, merge them in now.
46+ with open(tmp, encoding='utf-8') as data:
47+ self.cache.update(json.load(data))
48+ self._save_progress(self.cache)
49+ self.save_state()
50
51 def _scrape_gpg_emails(self, person):
52 """Find email addresses from one person's GPG keys."""
53@@ -158,36 +167,48 @@ class EmailPolicy(BasePolicy, Rest):
54
55 def apply_policy_impl(self, email_info, suite, source_name, source_data_tdist, source_data_srcdist, excuse):
56 """Send email if package is rejected."""
57+ max_age = 5 if excuse.is_valid else 1
58 series = self.options.series
59 version = source_data_srcdist.version
60- age = excuse.daysold or 0
61- rounded_age = int(age)
62+ age = int(excuse.daysold) or 0
63+ plural = '' if age == 1 else 's'
64 # an item is stuck if it's
65 # - old enough
66 # - not blocked
67 # - not temporarily rejected (e.g. by the autopkgtest policy when tests
68 # are still running)
69- stuck = age >= 3 and 'block' not in excuse.reason and \
70+ stuck = age >= max_age and 'block' not in excuse.reason and \
71 excuse.current_policy_verdict != PolicyVerdict.REJECTED_TEMPORARILY
72+ if self.dry_run:
73+ self.log("[email dry run] Considering: %s/%s: %s" %
74+ (source_name, version, "stuck" if stuck else "not stuck"))
75+ if not stuck:
76+ return PolicyVerdict.PASS
77
78 cached = self.cache.get(source_name, {}).get(version)
79 try:
80- emails, sent_age = cached
81- sent = (age - sent_age) < min(MAX_FREQUENCY, age / 2.0)
82+ emails, last_sent = cached
83+ # migration of older data
84+ last_sent = int(last_sent)
85+ last_due = int(math.pow(2, int(math.log(age + 2 - max_age, 2)))
86+ + max_age - 2)
87+ if last_due - max_age >= MAX_INTERVAL:
88+ last_due = int((age - max_age - MAX_INTERVAL) / MAX_INTERVAL) \
89+ * MAX_INTERVAL + max_age + MAX_INTERVAL
90+ if last_due < max_age:
91+ last_due = max_age
92+
93 except TypeError:
94 # This exception happens when source_name, version never seen before
95 emails = []
96- sent_age = 0
97- sent = False
98+ last_sent = 0
99+ last_due = max_age
100 if self.dry_run:
101- self.log("[email dry run] Considering: %s/%s: %s" %
102- (source_name, version, "stuck" if stuck else "not stuck"))
103- if stuck:
104- self.log("[email dry run] Age %d >= threshold %d: would email: %s" %
105- (age, max_age, self.lp_get_emails(source_name, version)))
106+ self.log("[email dry run] Age %d >= threshold %d: would email: %s" %
107+ (age, max_age, self.lp_get_emails(source_name, version)))
108 # don't update the cache file in dry run mode; we'll see all output each time
109 return PolicyVerdict.PASS
110- if stuck and not sent:
111+ if last_sent < last_due:
112 if not emails:
113 emails = self.lp_get_emails(source_name, version)
114 if emails:
115@@ -199,19 +220,29 @@ class EmailPolicy(BasePolicy, Rest):
116 server = smtplib.SMTP(self.email_host)
117 server.sendmail('noreply@canonical.com', emails, msg)
118 server.quit()
119- sent_age = age
120+ # record the age at which the mail should have been sent
121+ last_sent = last_due
122 except socket.error as err:
123 self.log("Failed to send mail! Is SMTP server running?")
124 self.log(err)
125- self.emails_by_pkg[source_name][version] = (emails, sent_age)
126- self.save_state()
127+ self.emails_by_pkg[source_name][version] = (emails, last_sent)
128+ self._save_progress(self.emails_by_pkg)
129 return PolicyVerdict.PASS
130
131- def save_state(self, britney=None):
132- """Write source ppa data to disk"""
133- tmp = self.filename + '.tmp'
134+ def _save_progress(self, my_data):
135+ """Checkpoint after each sent mail"""
136+ tmp = self.filename + '.new'
137 with open(tmp, 'w', encoding='utf-8') as data:
138- json.dump(self.emails_by_pkg, data)
139- os.rename(tmp, self.filename)
140+ json.dump(my_data, data)
141+ return tmp
142+
143+ def save_state(self, britney=None):
144+ """Save email notification status of all pending packages"""
145+ if not self.dry_run:
146+ try:
147+ os.rename(self.filename + '.new', self.filename)
148+ # if we haven't written any cache, don't clobber the old one
149+ except FileNotFoundError:
150+ pass
151 if britney:
152 self.log("Wrote email data to %s" % self.filename)
153diff --git a/tests/test_email.py b/tests/test_email.py
154index 1899d9c..f971cf8 100755
155--- a/tests/test_email.py
156+++ b/tests/test_email.py
157@@ -225,8 +225,11 @@ class T(unittest.TestCase):
158
159 @patch('britney2.policies.email.EmailPolicy.lp_get_emails')
160 @patch('britney2.policies.email.smtplib', autospec=True)
161- def test_smtp_repetition(self, smtp, lp):
162+ def smtp_repetition(self, smtp, lp, valid, expected):
163 """Resend mails periodically, with decreasing frequency."""
164+ if not isinstance(valid,list):
165+ valid = [valid]*len(expected)
166+ FakeExcuse.is_valid = valid
167 lp.return_value = ['email@address.com']
168 sendmail = smtp.SMTP().sendmail
169 e = EmailPolicy(FakeOptions, None)
170@@ -236,13 +239,34 @@ class T(unittest.TestCase):
171 previous = sendmail.call_count
172 age = hours / 24
173 FakeExcuse.daysold = age
174+ try:
175+ FakeExcuse.is_valid = valid[len(called)]
176+ except IndexError:
177+ # we've already gotten all the mails we expect
178+ pass
179 e.apply_policy_impl(None, None, 'unity8', None, FakeSourceData, FakeExcuse)
180 if sendmail.call_count > previous:
181 e.initialise(None) # Refill e.cache from disk
182 called.append(age)
183+ name, args, kwargs = sendmail.mock_calls[-1]
184+ text = args[2]
185+ self.assertNotIn(' 1 days.', text)
186+ self.assertSequenceEqual(called, expected)
187+
188+ def test_smtp_repetition(self):
189+ """Confirm that emails are sent at appropriate intervals."""
190 # Emails were sent when daysold reached these values:
191- self.assertSequenceEqual(called, [
192- 3.0, 6.0, 12.0, 24.0, 48.0, 78.0, 108.0, 138.0, 168.0, 198.0
193+ self.smtp_repetition(valid=False, expected=[
194+ 1, 3, 7, 15, 31, 61, 91, 121, 151, 181
195+ ])
196+ self.smtp_repetition(valid=True, expected=[
197+ 5, 7, 11, 19, 35, 65, 95, 125, 155, 185
198+ ])
199+ self.smtp_repetition(valid=[False, False, True], expected=[
200+ 1, 3, 5, 7, 11, 19, 35, 65, 95, 125, 155, 185
201+ ])
202+ self.smtp_repetition(valid=[False, False, True, False, True], expected=[
203+ 1, 3, 5, 7, 11, 19, 35, 65, 95, 125, 155, 185
204 ])
205
206

Subscribers

People subscribed via source and target branches