Skip to content

Commit fa14b10

Browse files
authored
Change how we use the send pending email task (#4175)
1 parent 90be065 commit fa14b10

File tree

5 files changed

+80
-125
lines changed

5 files changed

+80
-125
lines changed

backend/notifications/models.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ def send_email(
192192
if not recipient and not recipient_email:
193193
raise ValueError("Either recipient or recipient_email must be provided")
194194

195-
from notifications.tasks import send_pending_emails
195+
from notifications.tasks import send_pending_email
196196

197197
recipient_email = recipient_email or recipient.email
198198

@@ -207,7 +207,7 @@ def send_email(
207207
or settings.DEFAULT_FROM_EMAIL
208208
)
209209

210-
SentEmail.objects.create(
210+
sent_email = SentEmail.objects.create(
211211
email_template=self,
212212
conference=self.conference,
213213
from_email=from_email,
@@ -223,7 +223,7 @@ def send_email(
223223
bcc_addresses=self.bcc_addresses,
224224
)
225225

226-
transaction.on_commit(lambda: send_pending_emails.delay())
226+
transaction.on_commit(lambda: send_pending_email.delay(sent_email.id))
227227

228228
@property
229229
def is_custom(self):

backend/notifications/tasks.py

Lines changed: 42 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,63 @@
1+
from django.db import transaction
2+
13
import logging
24
from uuid import uuid4
3-
from pycon.celery_utils import OnlyOneAtTimeTask
45
from notifications.models import SentEmail
5-
from django.db import transaction
66
from pycon.celery import app
77
from django.core.mail import EmailMultiAlternatives
88
from django.core.mail import get_connection
99

1010
logger = logging.getLogger(__name__)
1111

1212

13-
@app.task(base=OnlyOneAtTimeTask)
14-
def send_pending_emails():
15-
pending_emails = (
16-
SentEmail.objects.pending().order_by("created").values_list("id", flat=True)
13+
def send_pending_email_failed(self, exc, task_id, args, kwargs, einfo):
14+
sent_email_id = args[0]
15+
16+
sent_email = SentEmail.objects.get(id=sent_email_id)
17+
sent_email.mark_as_failed()
18+
logger.error(
19+
"Failed to send email sent_email_id=%s",
20+
sent_email.id,
1721
)
18-
total_pending_emails = pending_emails.count()
1922

20-
if total_pending_emails == 0:
21-
return
2223

23-
logger.info("Found %s pending emails", pending_emails.count())
24+
@app.task(
25+
bind=True,
26+
autoretry_for=(Exception,),
27+
retry_backoff=True,
28+
max_retries=5,
29+
default_retry_delay=2,
30+
on_failure=send_pending_email_failed,
31+
)
32+
@transaction.atomic()
33+
def send_pending_email(self, sent_email_id: int):
34+
logger.info(
35+
"Sending sent_email=%s (attempt=%s of %s)",
36+
sent_email_id,
37+
self.request.retries,
38+
self.max_retries,
39+
)
2440

25-
email_backend_connection = get_connection()
41+
sent_email = (
42+
SentEmail.objects.select_for_update(skip_locked=True)
43+
.pending()
44+
.filter(id=sent_email_id)
45+
.first()
46+
)
2647

27-
for email_id in pending_emails.iterator():
28-
with transaction.atomic():
29-
sent_email = (
30-
SentEmail.objects.select_for_update(skip_locked=True)
31-
.filter(
32-
id=email_id,
33-
)
34-
.first()
35-
)
48+
if not sent_email:
49+
return
3650

37-
if not sent_email or not sent_email.is_pending:
38-
return
51+
email_backend_connection = get_connection()
3952

40-
try:
41-
message_id = send_email(sent_email, email_backend_connection)
42-
sent_email.mark_as_sent(message_id)
53+
message_id = send_email(sent_email, email_backend_connection)
54+
sent_email.mark_as_sent(message_id)
4355

44-
logger.info(
45-
"Email sent_email_id=%s sent with message_id=%s",
46-
sent_email.id,
47-
message_id,
48-
)
49-
except Exception as e:
50-
sent_email.mark_as_failed()
51-
logger.exception(
52-
"Failed to send email sent_email_id=%s error=%s",
53-
email_id,
54-
e,
55-
exc_info=e,
56-
)
56+
logger.info(
57+
"Email sent_email_id=%s sent with message_id=%s",
58+
sent_email.id,
59+
message_id,
60+
)
5761

5862

5963
def send_email(sent_email, email_backend_connection):

backend/notifications/tests/test_models.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ def test_send_email_template_to_recipient_email(
5252
reply_to="[email protected]",
5353
)
5454

55-
mock_send_pending_emails = mocker.patch(
56-
"notifications.tasks.send_pending_emails.delay"
55+
mock_send_pending_email = mocker.patch(
56+
"notifications.tasks.send_pending_email.delay"
5757
)
5858

5959
with django_capture_on_commit_callbacks(execute=True):
@@ -64,12 +64,12 @@ def test_send_email_template_to_recipient_email(
6464
},
6565
)
6666

67-
mock_send_pending_emails.assert_called_once()
68-
6967
sent_email = SentEmail.objects.get(
7068
email_template=email_template,
7169
)
7270

71+
mock_send_pending_email.assert_called_once_with(sent_email.id)
72+
7373
assert sent_email.recipient is None
7474
assert sent_email.recipient_email == "[email protected]"
7575

Lines changed: 31 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
1+
import smtplib
12
from unittest.mock import patch
3+
from uuid import uuid4
24
import time_machine
35
from django.core import mail
4-
from notifications.tasks import send_pending_emails
6+
from notifications.tasks import send_pending_email, send_pending_email_failed
57
from notifications.models import SentEmail
68
from notifications.tests.factories import SentEmailFactory
79

810

9-
def test_send_pending_emails_does_nothing_with_no_pending_emails():
10-
SentEmailFactory(status=SentEmail.Status.sent, sent_at="2020-01-01 12:00Z")
11-
SentEmailFactory(status=SentEmail.Status.sent, sent_at="2020-01-01 12:00Z")
12-
SentEmailFactory(status=SentEmail.Status.sent, sent_at="2020-01-01 12:00Z")
13-
SentEmailFactory(status=SentEmail.Status.sent, sent_at="2020-01-01 12:00Z")
11+
def test_send_pending_email_does_nothing_with_non_pending_email():
12+
sent_email = SentEmailFactory(
13+
status=SentEmail.Status.sent, sent_at="2020-01-01 12:00Z"
14+
)
1415

15-
send_pending_emails()
16+
send_pending_email(sent_email.id)
1617

1718
assert len(mail.outbox) == 0
1819

1920

20-
def test_send_pending_emails_task_sends_data_correctly():
21+
def test_send_pending_email_task_sends_data_correctly():
2122
pending_email_1 = SentEmailFactory(
2223
status=SentEmail.Status.pending,
2324
reply_to="[email protected]",
@@ -29,7 +30,8 @@ def test_send_pending_emails_task_sends_data_correctly():
2930
subject="subject",
3031
)
3132

32-
send_pending_emails()
33+
with time_machine.travel("2021-01-01 12:00Z", tick=False):
34+
send_pending_email(pending_email_1.id)
3335

3436
assert len(mail.outbox) == 1
3537

@@ -42,8 +44,16 @@ def test_send_pending_emails_task_sends_data_correctly():
4244
assert mail.outbox[0].alternatives == [("html body", "text/html")]
4345
assert mail.outbox[0].subject == "subject"
4446

47+
pending_email_1.refresh_from_db()
48+
49+
assert len(mail.outbox) == 1
50+
51+
assert pending_email_1.status == SentEmail.Status.sent
52+
assert pending_email_1.message_id.startswith("local-")
53+
assert pending_email_1.sent_at.isoformat() == "2021-01-01T12:00:00+00:00"
54+
4555

46-
def test_send_pending_emails_task_doesnt_double_send():
56+
def test_send_pending_email_task_doesnt_double_send():
4757
pending_email_1 = SentEmailFactory(status=SentEmail.Status.pending)
4858
original_qs = SentEmail.objects.select_for_update(skip_locked=True).filter(
4959
id=pending_email_1.id
@@ -57,81 +67,28 @@ def side_effect(*args, **kwargs):
5767
"notifications.tasks.SentEmail.objects.select_for_update",
5868
side_effect=side_effect,
5969
):
60-
send_pending_emails()
70+
send_pending_email(pending_email_1.id)
6171

6272
pending_email_1.refresh_from_db()
6373

6474
assert len(mail.outbox) == 0
6575

6676

67-
def test_send_pending_emails_task():
68-
pending_email_1 = SentEmailFactory(status=SentEmail.Status.pending)
69-
pending_email_2 = SentEmailFactory(status=SentEmail.Status.pending)
70-
sent_email_1 = SentEmailFactory(
71-
status=SentEmail.Status.sent, message_id="abc-abc", sent_at="2020-01-01 12:00Z"
72-
)
73-
74-
with time_machine.travel("2021-01-01 12:00Z", tick=False):
75-
send_pending_emails()
76-
77-
pending_email_1.refresh_from_db()
78-
pending_email_2.refresh_from_db()
79-
sent_email_1.refresh_from_db()
80-
81-
assert len(mail.outbox) == 2
82-
83-
assert mail.outbox[0].to == [pending_email_1.recipient_email]
84-
assert mail.outbox[1].to == [pending_email_2.recipient_email]
85-
86-
assert pending_email_1.status == SentEmail.Status.sent
87-
assert pending_email_1.message_id.startswith("local-")
88-
assert pending_email_1.sent_at.isoformat() == "2021-01-01T12:00:00+00:00"
89-
90-
assert pending_email_2.status == SentEmail.Status.sent
91-
assert pending_email_2.message_id.startswith("local-")
92-
assert pending_email_2.sent_at.isoformat() == "2021-01-01T12:00:00+00:00"
93-
94-
assert sent_email_1.status == SentEmail.Status.sent
95-
assert sent_email_1.message_id == "abc-abc"
96-
assert sent_email_1.sent_at.isoformat() == "2020-01-01T12:00:00+00:00"
97-
98-
99-
def test_send_pending_emails_handles_failures(mocker):
77+
def test_send_pending_email_failure():
10078
pending_email_1 = SentEmailFactory(
10179
status=SentEmail.Status.pending, created="2020-01-01 12:00Z"
10280
)
103-
pending_email_2 = SentEmailFactory(
104-
status=SentEmail.Status.pending, created="2020-01-02 12:00Z"
105-
)
106-
107-
original_method = SentEmail.mark_as_sent
108-
109-
def _side_effect(*args, **kwargs):
110-
if _side_effect.counter == 0:
111-
_side_effect.counter = 1
112-
raise ValueError("test")
11381

114-
return original_method(pending_email_2, *args, **kwargs)
115-
116-
_side_effect.counter = 0
117-
118-
mocker.patch("notifications.tasks.SentEmail.mark_as_sent", side_effect=_side_effect)
119-
120-
with time_machine.travel("2021-01-01 12:00Z", tick=False):
121-
send_pending_emails()
82+
send_pending_email_failed(
83+
None,
84+
smtplib.SMTPException("test"),
85+
uuid4().hex,
86+
(pending_email_1.id,),
87+
{},
88+
None,
89+
)
12290

12391
pending_email_1.refresh_from_db()
124-
pending_email_2.refresh_from_db()
125-
126-
assert len(mail.outbox) == 2
127-
128-
assert mail.outbox[0].to == [pending_email_1.recipient_email]
129-
assert mail.outbox[1].to == [pending_email_2.recipient_email]
13092

93+
assert len(mail.outbox) == 0
13194
assert pending_email_1.status == SentEmail.Status.failed
132-
assert pending_email_1.message_id == ""
133-
assert pending_email_1.sent_at is None
134-
135-
assert pending_email_2.status == SentEmail.Status.sent
136-
assert pending_email_2.message_id.startswith("local-")
137-
assert pending_email_2.sent_at.isoformat() == "2021-01-01T12:00:00+00:00"

backend/pycon/celery.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ def setup_periodic_tasks(sender, **kwargs):
2424
from schedule.tasks import process_schedule_items_videos_to_upload
2525
from files_upload.tasks import delete_unused_files
2626
from pycon.tasks import check_for_idle_heavy_processing_workers
27-
from notifications.tasks import send_pending_emails
2827

2928
add = sender.add_periodic_task
3029

@@ -48,8 +47,3 @@ def setup_periodic_tasks(sender, **kwargs):
4847
check_for_idle_heavy_processing_workers,
4948
name="Check for idle heavy processing workers",
5049
)
51-
add(
52-
timedelta(minutes=1),
53-
send_pending_emails,
54-
name="Send pending emails",
55-
)

0 commit comments

Comments
 (0)