Skip to content

Commit a5fe31d

Browse files
authored
Merge pull request #85 from thinkt4nk/master
Release 2.1.0
2 parents c64f815 + 7fb89f7 commit a5fe31d

File tree

8 files changed

+95
-19
lines changed

8 files changed

+95
-19
lines changed

entity_emailer/interface.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1+
from datetime import datetime
12
import json
23
import sys
34
import traceback
45

5-
from datetime import datetime
6-
from django.db import transaction
6+
from ambition_utils.transaction import durable
7+
from django.conf import settings
78
from django.core import mail
9+
from django.db import transaction
810
from entity_event import context_loader
911

1012
from entity_emailer.models import Email
1113
from entity_emailer.signals import pre_send, email_exception
12-
1314
from entity_emailer.utils import get_medium, get_from_email_address, get_subscribed_email_addresses, \
1415
create_email_message, extract_email_subject_from_html_content
1516

@@ -20,6 +21,7 @@ class EntityEmailerInterface(object):
2021
"""
2122

2223
@classmethod
24+
@durable
2325
def send_unsent_scheduled_emails(cls):
2426
"""
2527
Send out any scheduled emails that are unsent
@@ -30,7 +32,8 @@ def send_unsent_scheduled_emails(cls):
3032
email_medium = get_medium()
3133
to_send = Email.objects.filter(
3234
scheduled__lte=current_time,
33-
sent__isnull=True
35+
sent__isnull=True,
36+
num_tries__lt=settings.ENTITY_EMAILER_MAX_SEND_MESSAGE_TRIES
3437
).select_related(
3538
'event__source'
3639
).prefetch_related(
@@ -95,12 +98,13 @@ def send_unsent_scheduled_emails(cls):
9598
try:
9699
# Send mail
97100
connection.send_messages([email.get('message')])
101+
# Update the email model sent value
102+
email_model = email.get('model')
103+
email_model.sent = current_time
104+
email_model.save(update_fields=['sent'])
98105
except Exception as e:
99106
cls.save_email_exception(email.get('model'), e)
100107

101-
# Update the emails as sent
102-
to_send.update(sent=current_time)
103-
104108
@staticmethod
105109
def convert_events_to_emails():
106110
"""
@@ -163,7 +167,8 @@ def save_email_exception(cls, email, e):
163167
exception_message += ': {}'.format(json.dumps(e.to_dict()))
164168

165169
email.exception = exception_message
166-
email.save(update_fields=['exception'])
170+
email.num_tries += 1
171+
email.save(update_fields=['exception', 'num_tries'])
167172

168173
# Fire the email exception event
169174
email_exception.send(
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 2.2.13 on 2021-03-02 21:05
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('entity_emailer', '0003_email_exception'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='email',
15+
name='num_tries',
16+
field=models.IntegerField(default=0),
17+
),
18+
]

entity_emailer/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ class Email(models.Model):
105105
# The time that the email was actually sent, or None if the email is still waiting to be sent
106106
sent = models.DateTimeField(null=True, default=None)
107107

108+
# Number of attempts to send this email message, used only in retry logic
109+
num_tries = models.IntegerField(default=0)
110+
108111
# Any exception that occurred when attempting to send the email last
109112
exception = models.TextField(default=None, null=True)
110113

entity_emailer/tests/interface_tests.py

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from django.core import mail
66
from django.core.mail import EmailMultiAlternatives
77
from django.core.management import call_command
8+
from django.db import utils
89
from django.test import TestCase, SimpleTestCase
910
from django.test.utils import override_settings
1011
from django_dynamic_fixture import G
@@ -384,6 +385,7 @@ class SendUnsentScheduledEmailsTest(TestCase):
384385
def setUp(self):
385386
G(Medium, name='email')
386387

388+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
387389
@patch('entity_emailer.interface.get_subscribed_email_addresses')
388390
@patch.object(Event, 'render', spec_set=True)
389391
def test_sends_all_scheduled_emails_no_email_addresses(self, render_mock, address_mock):
@@ -394,6 +396,7 @@ def test_sends_all_scheduled_emails_no_email_addresses(self, render_mock, addres
394396
EntityEmailerInterface.send_unsent_scheduled_emails()
395397
self.assertEqual(len(mail.outbox), 0)
396398

399+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
397400
@patch('entity_emailer.interface.get_subscribed_email_addresses')
398401
@patch.object(Event, 'render', spec_set=True)
399402
def test_sends_all_scheduled_emails(self, render_mock, address_mock):
@@ -407,6 +410,7 @@ def test_sends_all_scheduled_emails(self, render_mock, address_mock):
407410

408411
self.assertEqual(2, mock_connection.return_value.__enter__.return_value.send_messages.call_count)
409412

413+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
410414
@patch('entity_emailer.interface.pre_send')
411415
@patch('entity_emailer.interface.get_subscribed_email_addresses')
412416
@patch.object(Event, 'render', spec_set=True)
@@ -439,6 +443,7 @@ def test_send_signals(self, render_mock, address_mock, mock_pre_send):
439443
})
440444
self.assertIsInstance(kwargs['message'], EmailMultiAlternatives)
441445

446+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
442447
@patch('entity_emailer.interface.get_subscribed_email_addresses')
443448
@patch.object(Event, 'render', spec_set=True)
444449
def test_sends_email_with_specified_from_address(self, render_mock, address_mock):
@@ -453,6 +458,7 @@ def test_sends_email_with_specified_from_address(self, render_mock, address_mock
453458
args = mock_connection.return_value.__enter__.return_value.send_messages.call_args
454459
self.assertEqual(args[0][0][0].from_email, from_address)
455460

461+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
456462
@patch('entity_emailer.interface.get_subscribed_email_addresses')
457463
@patch.object(Event, 'render', spec_set=True)
458464
def test_sends_no_future_emails(self, render_mock, address_mock):
@@ -462,6 +468,7 @@ def test_sends_no_future_emails(self, render_mock, address_mock):
462468
EntityEmailerInterface.send_unsent_scheduled_emails()
463469
self.assertEqual(len(mail.outbox), 0)
464470

471+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
465472
@patch('entity_emailer.interface.get_subscribed_email_addresses')
466473
@patch.object(Event, 'render', spec_set=True)
467474
def test_sends_no_sent_emails(self, render_mock, address_mock):
@@ -471,6 +478,7 @@ def test_sends_no_sent_emails(self, render_mock, address_mock):
471478
EntityEmailerInterface.send_unsent_scheduled_emails()
472479
self.assertEqual(len(mail.outbox), 0)
473480

481+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
474482
@patch('entity_emailer.interface.get_subscribed_email_addresses')
475483
@patch.object(Event, 'render', spec_set=True)
476484
def test_updates_times(self, render_mock, address_mock):
@@ -481,6 +489,7 @@ def test_updates_times(self, render_mock, address_mock):
481489
sent_email = Email.objects.filter(sent__isnull=False)
482490
self.assertEqual(sent_email.count(), 1)
483491

492+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
484493
@patch('entity_emailer.interface.email_exception')
485494
@patch('entity_emailer.interface.get_subscribed_email_addresses')
486495
@patch.object(Event, 'render', spec_set=True)
@@ -506,19 +515,26 @@ def test_exceptions(self, render_mock, address_mock, mock_email_exception):
506515
with patch(settings.EMAIL_BACKEND) as mock_connection:
507516
EntityEmailerInterface.send_unsent_scheduled_emails()
508517

509-
# Assert that both emails were marked as sent
510-
self.assertEqual(Email.objects.filter(sent__isnull=False).count(), 2)
518+
# Assert that only one email was marked as sent
519+
self.assertEqual(Email.objects.filter(sent__isnull=False).count(), 1)
511520

512521
# Assert that only one email is actually sent through backend
513522
self.assertEquals(1, mock_connection.call_count)
514-
# Assert that one email raised an exception
515-
exception_email = Email.objects.get(sent__isnull=False, exception__isnull=False)
523+
# Assert that one email raised an exception and that the tries count was updated
524+
exception_email = Email.objects.get(exception__isnull=False, num_tries=1)
516525
self.assertIsNotNone(exception_email)
517526
self.assertTrue('Exception: test' in exception_email.exception)
518527

528+
def test_send_unsent_emails_is_durable(self):
529+
"""
530+
Verify that the process to send unsent emails is durable and will not be rolled-back on exit from any caller
531+
"""
532+
self.assertRaises(utils.ProgrammingError, EntityEmailerInterface.send_unsent_scheduled_emails)
533+
534+
@override_settings(DISABLE_DURABILITY_CHECKING=True, ENTITY_EMAILER_MAX_SEND_MESSAGE_TRIES=2)
519535
@patch.object(Event, 'render', spec_set=True)
520536
@patch('entity_emailer.interface.get_subscribed_email_addresses')
521-
def test_send_exceptions(self, mock_get_subscribed_addresses, mock_render):
537+
def test_send_exceptions_and_retry(self, mock_get_subscribed_addresses, mock_render):
522538
"""
523539
Verifies that when a single email raises an exception from within the backend, the batch is still
524540
updated as sent and the failed email is saved with the exception
@@ -545,16 +561,44 @@ def to_dict(self):
545561

546562
EntityEmailerInterface.send_unsent_scheduled_emails()
547563

548-
# Verify that both emails are marked as sent
549-
self.assertEquals(2, Email.objects.filter(sent__isnull=False).count())
564+
# Verify that only one email is marked as sent
565+
self.assertEquals(1, Email.objects.filter(sent__isnull=False).count())
550566
# Verify that the failed email was saved with the exception
551-
actual_failed_email = Email.objects.get(sent__isnull=False, exception__isnull=False)
567+
actual_failed_email = Email.objects.get(exception__isnull=False, num_tries=1)
552568
self.assertEquals(failed_email.id, actual_failed_email.id)
553569
self.assertEquals(
554570
'test: {}'.format(json.dumps({'message': 'test'})),
555571
actual_failed_email.exception
556572
)
557573

574+
# Verify that a subsequent attempt to send unscheduled emails will retry the failed email
575+
with patch(settings.EMAIL_BACKEND) as mock_connection:
576+
# Mock side effect for sending email
577+
mock_connection.return_value.__enter__.return_value.send_messages.side_effect = (
578+
TestEmailSendMessageException('test')
579+
)
580+
581+
EntityEmailerInterface.send_unsent_scheduled_emails()
582+
583+
# Verify only called once, with the failed email
584+
self.assertEquals(1, mock_connection.return_value.__enter__.return_value.send_messages.call_count)
585+
586+
# Verify num tries updated
587+
actual_failed_email = Email.objects.get(exception__isnull=False, num_tries=2)
588+
self.assertEquals(failed_email.id, actual_failed_email.id)
589+
590+
# Verify that a subsequent attempt to send unscheduled emails will find no emails to send
591+
with patch(settings.EMAIL_BACKEND) as mock_connection:
592+
593+
EntityEmailerInterface.send_unsent_scheduled_emails()
594+
595+
# Verify only called once, with the failed email
596+
self.assertEquals(0, mock_connection.return_value.__enter__.return_value.send_messages.call_count)
597+
598+
# Verify num tries not updated
599+
actual_failed_email = Email.objects.get(exception__isnull=False, num_tries=2)
600+
self.assertEquals(failed_email.id, actual_failed_email.id)
601+
558602

559603
class CreateEmailObjectTest(TestCase):
560604
def test_no_html(self):

entity_emailer/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '2.0.4'
1+
__version__ = '2.1.0'

release_notes.rst

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
Release Notes
22
=============
33

4+
v2.1.0
5+
------
6+
* More durable handling and retry logic for failures in bulk send unsent emails process
7+
48
v2.0.4
59
------
610
* Bugfix for updated interface
711

8-
v2.0.3
12+
v2.0.2
913
------
10-
* Merging v2 hotfixes
14+
* Fix unique constraint when bulk creating emails
1115

1216
v2.0.1
1317
------

requirements/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ django-db-mutex>=2.0.0
44
django-entity>=5.0.0
55
django-entity-event>=2.0.0
66
ambition-django-uuidfield>=0.5.0
7+
ambition-utils>=2.2.0
78

89
beautifulsoup4>=4.3.2

settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def configure_settings():
3535
raise RuntimeError('Unsupported test DB {0}'.format(test_db))
3636

3737
settings.configure(
38+
ENTITY_EMAILER_MAX_SEND_MESSAGE_TRIES=3,
3839
DATABASES={
3940
'default': db_config,
4041
},

0 commit comments

Comments
 (0)