Skip to content

Commit 1c03524

Browse files
committed
requested changes
1 parent f60de88 commit 1c03524

5 files changed

Lines changed: 34 additions & 46 deletions

File tree

readme.md

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -80,34 +80,13 @@ The following configuration options are available:
8080
| `aboutbits.emailservice.scheduling.enabled` | true | Enables the scheduler sending the emails. |
8181
| `aboutbits.emailservice.scheduling.cleanup.enabled` | true | Enables cleanup of attachment files after sending. |
8282
| `aboutbits.emailservice.scheduling.interval` | 30000 | Milliseconds delay between runs of the scheduler. |
83-
| `aboutbits.emailservice.scheduling.stuck-sending-recovery-threshold` | PT5M | How long an email may stay in `SENDING` before being considered abandoned (crashed pod) and eligible to be re-claimed. |
84-
| `aboutbits.emailservice.scheduling.max-attempts` | 3 | Maximum number of send attempts before an email is marked as `ERROR`. Applies only to the scheduled retry loop. |
83+
| `aboutbits.emailservice.scheduling.stuck-sending-recovery-threshold` | PT30M | How long an email may stay in `SENDING` before being considered abandoned (crashed pod) and eligible to be re-claimed. Must comfortably exceed the worst-case SMTP send duration: JavaMail's default connect/read/write timeouts are infinite, so configure `spring.mail.properties.mail.smtp.connectiontimeout`, `spring.mail.properties.mail.smtp.timeout` and `spring.mail.properties.mail.smtp.writetimeout` well below this threshold, otherwise a slow in-flight send can be re-claimed by another pod and delivered twice. |
84+
| `aboutbits.emailservice.scheduling.max-attempts` | 3 | Maximum number of send attempts before an email is marked as `ERROR`. Applies only to the scheduled retry loop. Failed attempts are retried with exponential backoff (`scheduling.interval` × 2^attempts); with the defaults a persistently failing email runs attempt 1 → +60s → attempt 2 → +120s → attempt 3 → `ERROR`. |
8585

8686
## Multi-pod deployments
8787

88-
The scheduler is safe to run on every pod concurrently. Each pass performs
89-
three independent steps:
90-
91-
1. **Candidate scan** — a plain `SELECT` returns up to `batch-size` ids of
92-
emails whose `scheduled_at` has passed and whose state is `PENDING` or `SENDING`
93-
older than `stuck-sending-recovery-threshold` (crashed-pod recovery). `ERROR`
94-
is terminal: rows that exhausted `max-attempts` are never re-picked
95-
automatically — an operator can reset them to `PENDING` if a retry is desired.
96-
Two pods may see overlapping ids at this step, and that's fine — the atomic
97-
claim below arbitrates.
98-
2. **Atomic claim** — for each candidate id, a single compare-and-set
99-
`UPDATE … SET state = SENDING … WHERE id = ? AND state IN (PENDING, ERROR, staleSENDING)`
100-
is issued. The database serializes concurrent updates against the same row so
101-
exactly one pod sees `rowsAffected = 1` and owns that email; the other pods
102-
see `0` and move on.
103-
3. **Send and persist** — the winning pod calls SMTP outside any database
104-
transaction and then writes the final `SENT` / `ERROR` / `PENDING` state through a small `save()`.
105-
106-
Crash recovery: if a pod dies between "claim" and "persist result", the row
107-
stays in `SENDING` with its `execution_start_time` frozen. After
108-
`stuck-sending-recovery-threshold` any pod's next pass picks it up as a
109-
candidate again and retries. Repeated crashes therefore count against
110-
`max-attempts` and eventually escalate the row to `ERROR` rather than looping forever.
88+
The scheduler is safe to run on every pod concurrently: the database arbitrates which pod sends each email.
89+
Delivery is at-least-once - if a pod crashes after the SMTP server accepted the message but before the result was persisted, the email may be sent again on recovery.
11190

11291
## Local development:
11392

src/main/java/it/aboutbits/springboot/emailservice/EmailServiceConfiguration.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import org.springframework.context.annotation.Bean;
2323
import org.springframework.jdbc.core.JdbcTemplate;
2424
import org.springframework.mail.javamail.JavaMailSender;
25+
import org.springframework.transaction.PlatformTransactionManager;
2526

2627
import java.time.Duration;
2728
import java.util.List;
@@ -57,15 +58,17 @@ public ManageEmail manageEmail(
5758
AttachmentDataSource attachmentDataSource,
5859
EmailMapper emailMapper,
5960
@Value("${aboutbits.emailservice.scheduling.max-attempts:3}") int maxAttempts,
60-
@Value("${aboutbits.emailservice.scheduling.interval:30000}") long schedulerIntervalMillis
61+
@Value("${aboutbits.emailservice.scheduling.interval:30000}") long schedulerIntervalMillis,
62+
PlatformTransactionManager transactionManager
6163
) {
6264
return new ManageEmail(
6365
emailRepository,
6466
javaMailSender,
6567
attachmentDataSource,
6668
emailMapper,
6769
maxAttempts,
68-
Duration.ofMillis(schedulerIntervalMillis)
70+
Duration.ofMillis(schedulerIntervalMillis),
71+
transactionManager
6972
);
7073
}
7174

@@ -75,7 +78,7 @@ public SendScheduledEmails sendScheduledEmails(
7578
QueryEmail queryEmail,
7679
ManageEmail manageEmail,
7780
List<EmailSchedulerCallback> callbacks,
78-
@Value("${aboutbits.emailservice.scheduling.stuck-sending-recovery-threshold:PT5M}") Duration stuckSendingRecoveryThreshold
81+
@Value("${aboutbits.emailservice.scheduling.stuck-sending-recovery-threshold:PT30M}") Duration stuckSendingRecoveryThreshold
7982
) {
8083
return new SendScheduledEmails(queryEmail, manageEmail, callbacks, stuckSendingRecoveryThreshold);
8184
}

src/main/java/it/aboutbits/springboot/emailservice/lib/application/ManageEmail.java

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
1818
import org.springframework.core.io.ByteArrayResource;
1919
import org.springframework.mail.javamail.JavaMailSender;
2020
import org.springframework.mail.javamail.MimeMessageHelper;
21+
import org.springframework.transaction.PlatformTransactionManager;
22+
import org.springframework.transaction.TransactionDefinition;
2123
import org.springframework.transaction.annotation.Transactional;
24+
import org.springframework.transaction.support.TransactionTemplate;
2225
import org.springframework.validation.annotation.Validated;
2326

2427
import java.io.IOException;
@@ -40,21 +43,26 @@ public class ManageEmail {
4043
private final EmailMapper emailMapper;
4144
private final int maxAttempts;
4245
private final Duration schedulerInterval;
46+
private final TransactionTemplate transactionTemplate;
4347

4448
public ManageEmail(
4549
EmailRepository emailRepository,
4650
JavaMailSender mailSender,
4751
AttachmentDataSource attachmentDataSource,
4852
EmailMapper emailMapper,
4953
int maxAttempts,
50-
Duration schedulerInterval
54+
Duration schedulerInterval,
55+
PlatformTransactionManager transactionManager
5156
) {
5257
this.emailRepository = emailRepository;
5358
this.mailSender = mailSender;
5459
this.attachmentDataSource = attachmentDataSource;
5560
this.emailMapper = emailMapper;
5661
this.maxAttempts = maxAttempts;
5762
this.schedulerInterval = schedulerInterval;
63+
// Persist the final email state in its own, independent transaction for sendOrFail to never make it roll back
64+
this.transactionTemplate = new TransactionTemplate(transactionManager);
65+
this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
5866
}
5967

6068
public EmailDto schedule(@Valid EmailParameter parameter) throws EmailException {
@@ -78,10 +86,8 @@ public EmailDto sendOrFail(@Valid EmailParameter parameter) throws EmailExceptio
7886
throw new EmailException(e);
7987
}
8088

81-
email.setState(EmailState.SENDING);
8289
email.setExecutionStartTime(OffsetDateTime.now());
8390
email.incrementAttempts();
84-
emailRepository.save(email);
8591

8692
try {
8793
sendMail(email);
@@ -94,7 +100,7 @@ public EmailDto sendOrFail(@Valid EmailParameter parameter) throws EmailExceptio
94100
email.setErrorMessage(e.getMessage());
95101
}
96102

97-
var savedEmail = emailRepository.save(email);
103+
var savedEmail = transactionTemplate.execute(_ -> emailRepository.save(email));
98104

99105
if (savedEmail.getState() == EmailState.ERROR) {
100106
throw new EmailException("Failed to send email [id=%s, providerMessage=%s]"
@@ -121,14 +127,15 @@ Email completeClaimedSend(Email email) {
121127
} catch (Exception e) {
122128
email.setExecutionEndTime(OffsetDateTime.now());
123129
email.setErrorMessage(e.getMessage());
124-
if (email.getAttempts() > maxAttempts) {
130+
if (email.getAttempts() >= maxAttempts) {
125131
log.error("Failed to send email: {}", email.getId(), e);
126132
email.setState(EmailState.ERROR);
127133
} else {
128134
log.warn("Failed to send email: {}; Will be tried again", email.getId(), e);
129135
email.setState(EmailState.PENDING);
130136
email.setScheduledAt(
131-
OffsetDateTime.now().plus(schedulerInterval.multipliedBy(email.getAttempts() * 2L))
137+
OffsetDateTime.now()
138+
.plus(schedulerInterval.multipliedBy((long) Math.pow(2, email.getAttempts())))
132139
);
133140
}
134141
}
@@ -199,14 +206,13 @@ private void sendMail(
199206
payload.close();
200207
}
201208

202-
203209
mailSender.send(message);
204210
}
205211

206212
private Email fromParameter(EmailParameter parameter) throws AttachmentException {
207213
var emailData = parameter.email();
208214

209-
final var email = new Email();
215+
var email = new Email();
210216
email.setState(EmailState.PENDING);
211217
email.setScheduledAt(parameter.scheduledAt());
212218
email.setContent(new EmailContent(

src/main/resources/META-INF/additional-spring-configuration-metadata.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,13 @@
2727
{
2828
"name": "aboutbits.emailservice.scheduling.stuck-sending-recovery-threshold",
2929
"type": "java.time.Duration",
30-
"description": "How long an email may stay in the SENDING state before being considered abandoned (crashed pod) and eligible to be re-claimed by another pod.",
31-
"defaultValue": "PT5M"
30+
"description": "How long an email may stay in the SENDING state before being considered abandoned (crashed pod) and eligible to be re-claimed by another pod. Must comfortably exceed the worst-case SMTP send duration: JavaMail's default connect/read/write timeouts are infinite, so configure spring.mail.properties.mail.smtp.connectiontimeout, spring.mail.properties.mail.smtp.timeout and spring.mail.properties.mail.smtp.writetimeout well below this threshold, otherwise a slow in-flight send can be re-claimed by another pod and delivered twice.",
31+
"defaultValue": "PT30M"
3232
},
3333
{
3434
"name": "aboutbits.emailservice.scheduling.max-attempts",
3535
"type": "java.lang.Integer",
36-
"description": "Maximum number of send attempts before an email is marked as ERROR. Applies to the scheduled retry loop; the synchronous sendOrFail path is fail-fast and does not retry.",
36+
"description": "Maximum number of send attempts before an email is marked as ERROR. Applies to the scheduled retry loop; the synchronous sendOrFail path is fail-fast and does not retry. Failed attempts are retried with exponential backoff (scheduling.interval x 2^attempts); with the defaults a persistently failing email runs attempt 1 -> +60s -> attempt 2 -> +120s -> attempt 3 -> ERROR.",
3737
"defaultValue": 3
3838
}
3939
]

src/test/java/it/aboutbits/springboot/emailservice/lib/application/SendScheduledEmailsTest.java

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ void givenRetryableFailure_sendEmails_shouldRescheduleWithBackoffAndIncrementAtt
8181
.satisfies(email -> {
8282
assertThat(email.getState()).isEqualTo(EmailState.PENDING);
8383
assertThat(email.getAttempts()).isEqualTo(1);
84-
// First retry backoff: attempts (1) * 2 * scheduler interval.
84+
// First retry backoff: 2^attempts * scheduler interval = 2 * scheduler interval
8585
assertThat(email.getScheduledAt())
8686
.isAfterOrEqualTo(beforePass.plusSeconds(2L * SCHEDULER_INTERVAL_SECONDS));
8787
assertThat(email.getErrorMessage()).contains("smtp blip");
@@ -94,7 +94,7 @@ void givenAttemptsAlreadyAtBudget_sendEmails_shouldEscalateToError() {
9494
doThrow(new MailSendException("smtp down")).when(javaMailSender).send(any(MimeMessage.class));
9595
emailRepository.save(
9696
EmailFactory.once()
97-
.attempts(MAX_ATTEMPTS)
97+
.attempts(MAX_ATTEMPTS - 1)
9898
.scheduledAt(OffsetDateTime.now().minusSeconds(30))
9999
.build()
100100
);
@@ -104,10 +104,10 @@ void givenAttemptsAlreadyAtBudget_sendEmails_shouldEscalateToError() {
104104
assertThat(emailRepository.findAll())
105105
.singleElement()
106106
.satisfies(email -> {
107-
// The atomic claim UPDATE incremented attempts from MAX_ATTEMPTS to MAX_ATTEMPTS+1,
108-
// which is > threshold, so the failure escalates to ERROR.
107+
// The atomic claim UPDATE incremented attempts from MAX_ATTEMPTS-1 to MAX_ATTEMPTS,
108+
// which is equal to the threshold, so the failure escalates to ERROR.
109109
assertThat(email.getState()).isEqualTo(EmailState.ERROR);
110-
assertThat(email.getAttempts()).isEqualTo(MAX_ATTEMPTS + 1);
110+
assertThat(email.getAttempts()).isEqualTo(MAX_ATTEMPTS);
111111
assertThat(email.getErrorMessage()).contains("smtp down");
112112
});
113113
}
@@ -117,7 +117,7 @@ void givenErrorRow_sendEmails_shouldNotRetry() {
117117
emailRepository.save(
118118
EmailFactory.once()
119119
.state(EmailState.ERROR)
120-
.attempts(MAX_ATTEMPTS + 1)
120+
.attempts(MAX_ATTEMPTS)
121121
.scheduledAt(OffsetDateTime.now().minusMinutes(1))
122122
.errorMessage("previous permanent failure")
123123
.build()
@@ -129,7 +129,7 @@ void givenErrorRow_sendEmails_shouldNotRetry() {
129129
.singleElement()
130130
.satisfies(email -> {
131131
assertThat(email.getState()).isEqualTo(EmailState.ERROR);
132-
assertThat(email.getAttempts()).isEqualTo(MAX_ATTEMPTS + 1);
132+
assertThat(email.getAttempts()).isEqualTo(MAX_ATTEMPTS);
133133
});
134134
verify(javaMailSender, times(0)).send(any(MimeMessage.class));
135135
}

0 commit comments

Comments
 (0)