Skip to content

Commit 9c823a8

Browse files
authored
make the scheduled attachment cleanup multi pod save (#13)
* first draft for concurrency using an approach at the database level * Revert "first draft for concurrency using an approach at the database level" This reverts commit 15c22d4. * refactor Email entity by embedding EmailContent and adding new fields * solve concurrency issue between pods using atomic compare-and-set; Add new configurables and make sendOrFail not retry * add test for concurrency and schedule functionality * update readme.md * test fixes * truncate OffsetDateTime to MICROS for pipeline to run successfully * remove unnecessary errorMessage set * requested changes * consider all error cases that can happen on a schedule pass * remove the first try catch since it is overkill * make the scheduled attachment cleanup multip pod save * add tests * requested changes
1 parent e37c8a7 commit 9c823a8

10 files changed

Lines changed: 269 additions & 18 deletions

File tree

readme.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,14 @@ The following configuration options are available:
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. |
8383
| `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.stuck-cleanup-recovery-threshold` | PT30M | How long an email may keep its attachment-cleanup lock before the cleanup is considered abandoned (crashed pod) and eligible to be re-claimed. Must comfortably exceed the worst-case duration of releasing all attachments of a single email, otherwise a slow in-flight cleanup can be re-claimed by another pod and its attachments released twice (harmless only if `AttachmentDataSource.releaseAttachment` is idempotent). |
8485
| `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`. |
8586

8687
## Multi-pod deployments
8788

88-
The scheduler is safe to run on every pod concurrently: the database arbitrates which pod sends each email.
89+
Both schedulers are safe to run on every pod concurrently: the database arbitrates which pod handles each email.
8990
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.
91+
Attachment Cleanup is at-least-once - if a pod crashes after releasing the attachments but before the result was persisted, the cleanup may be retried on recovery.
9092

9193
## Local development:
9294

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,10 @@ public SendScheduledEmails sendScheduledEmails(
8888
public CleanupAttachmentFiles cleanupAttachments(
8989
QueryEmail queryEmail,
9090
ManageEmail manageEmail,
91-
List<AttachmentCleanerCallback> callbacks
91+
List<AttachmentCleanerCallback> callbacks,
92+
@Value("${aboutbits.emailservice.scheduling.stuck-cleanup-recovery-threshold:PT30M}") Duration stuckCleanupRecoveryThreshold
9293
) {
93-
return new CleanupAttachmentFiles(queryEmail, manageEmail, callbacks);
94+
return new CleanupAttachmentFiles(queryEmail, manageEmail, callbacks, stuckCleanupRecoveryThreshold);
9495
}
9596

9697
@Bean

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

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33

44
import it.aboutbits.springboot.emailservice.lib.AttachmentCleanerCallback;
5-
import it.aboutbits.springboot.emailservice.lib.exception.AttachmentException;
65
import lombok.RequiredArgsConstructor;
76
import lombok.extern.log4j.Log4j2;
87
import org.jspecify.annotations.NullMarked;
98
import org.springframework.scheduling.annotation.Scheduled;
109

1110
import java.time.Duration;
11+
import java.time.OffsetDateTime;
1212
import java.util.List;
1313

1414
@RequiredArgsConstructor
@@ -20,6 +20,7 @@ public class CleanupAttachmentFiles {
2020
private final QueryEmail queryEmail;
2121
private final ManageEmail manageEmail;
2222
private final List<AttachmentCleanerCallback> callbacks;
23+
private final Duration stuckCleanupRecoveryThreshold;
2324

2425
private long lastInfoLogMillis = System.currentTimeMillis();
2526
private long silentRuns = 0;
@@ -29,15 +30,25 @@ public class CleanupAttachmentFiles {
2930
void cleanupAttachments() {
3031
logStartOfPass();
3132

32-
var emailsToCleanup = queryEmail.readyToCleanup();
33+
var staleCleanupBefore = OffsetDateTime.now().minus(stuckCleanupRecoveryThreshold);
34+
var candidateIds = queryEmail.candidateIdsToCleanup(staleCleanupBefore);
3335

36+
var countClaimed = 0;
3437
var countCleaned = 0;
3538
var countError = 0;
36-
for (var email : emailsToCleanup) {
39+
for (var id : candidateIds) {
40+
var claimed = manageEmail.tryClaimForCleanup(id, staleCleanupBefore);
41+
42+
if (claimed.isEmpty()) {
43+
// Lost race to another pod; Skip
44+
continue;
45+
}
46+
countClaimed++;
47+
3748
try {
38-
manageEmail.cleanupAttachments(email);
49+
manageEmail.completeClaimedCleanup(claimed.get());
3950
countCleaned++;
40-
} catch (AttachmentException e) {
51+
} catch (Exception _) {
4152
countError++;
4253
}
4354
}
@@ -46,7 +57,7 @@ void cleanupAttachments() {
4657

4758
for (var callback : callbacks) {
4859
callback.report(new AttachmentCleanerCallback.Report(
49-
emailsToCleanup.size(),
60+
countClaimed,
5061
countCleaned,
5162
countError
5263
));

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ updated_at timestamp with time zone default now() not null,
7474
alter table email_service_emails add column if not exists execution_start_time timestamp with time zone;
7575
alter table email_service_emails add column if not exists execution_end_time timestamp with time zone;
7676
77+
alter table email_service_emails add column if not exists cleanup_start_time timestamp with time zone;
78+
7779
update email_service_emails
7880
set execution_end_time = sent_at
7981
where sent_at is not null

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public ManageEmail(
6060
this.emailMapper = emailMapper;
6161
this.maxAttempts = maxAttempts;
6262
this.schedulerInterval = schedulerInterval;
63-
// Persist the final email state in its own, independent transaction for sendOrFail to never make it roll back
63+
// Persist the final email state in its own, independent transaction to never make it roll back
6464
this.transactionTemplate = new TransactionTemplate(transactionManager);
6565
this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
6666
}
@@ -142,12 +142,20 @@ Email completeClaimedSend(Email email) {
142142
return transactionTemplate.execute(_ -> emailRepository.save(email));
143143
}
144144

145-
void cleanupAttachments(final Email email) throws AttachmentException {
145+
// Atomically marks a not-yet-cleaned SENT row as cleanup-in-progress
146+
@Transactional
147+
Optional<Email> tryClaimForCleanup(long id, OffsetDateTime staleCleanupBefore) {
148+
var claimed = emailRepository.claimForCleanup(id, OffsetDateTime.now(), staleCleanupBefore);
149+
return claimed == 0 ? Optional.empty() : emailRepository.findById(id);
150+
}
151+
152+
// Actually release the attachment payloads of the claimed email.
153+
void completeClaimedCleanup(final Email email) throws AttachmentException {
146154
for (var attachment : email.getAttachments()) {
147155
attachmentDataSource.releaseAttachment(attachment.getFileReference());
148156
}
149157
email.setAttachmentsCleaned(true);
150-
emailRepository.save(email);
158+
transactionTemplate.execute(_ -> emailRepository.save(email));
151159
}
152160

153161
private void sendMail(Email email) throws MessagingException, IOException, AttachmentException {

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import it.aboutbits.springboot.emailservice.lib.EmailDto;
55
import it.aboutbits.springboot.emailservice.lib.EmailState;
66
import it.aboutbits.springboot.emailservice.lib.jpa.EmailRepository;
7-
import it.aboutbits.springboot.emailservice.lib.model.Email;
87
import lombok.RequiredArgsConstructor;
98
import org.jspecify.annotations.NullMarked;
109
import org.springframework.data.domain.Page;
@@ -47,8 +46,8 @@ List<Long> candidateIdsToSend(OffsetDateTime staleSendingBefore) {
4746
);
4847
}
4948

50-
List<Email> readyToCleanup() {
51-
return emailRepository.findReadyToCleanup();
49+
List<Long> candidateIdsToCleanup(OffsetDateTime staleCleanupBefore) {
50+
return emailRepository.findCandidateIdsToCleanup(staleCleanupBefore);
5251
}
5352

5453
public Optional<EmailDto> byId(long id) {

src/main/java/it/aboutbits/springboot/emailservice/lib/jpa/EmailRepository.java

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,38 @@ int claimForSend(
8484
@Param("staleSendingBefore") OffsetDateTime staleSendingBefore
8585
);
8686

87-
@EntityGraph(value = Email.DEFAULT_ENTITY_GRAPH)
87+
// Plain read, no locking -> two pods may see overlapping candidate sets.
88+
// The atomic UPDATE in claimForCleanup arbitrates the actual claim.
89+
// Includes rows whose cleanup was abandoned by a crashed pod (past the stale threshold).
8890
@Query("""
89-
select e from Email e
91+
select e.id from Email e
9092
where e.attachmentsCleaned = false
9193
and e.state = it.aboutbits.springboot.emailservice.lib.EmailState.SENT
94+
and (
95+
e.cleanupStartTime is null
96+
or e.cleanupStartTime < :staleCleanupBefore
97+
)
9298
""")
93-
List<Email> findReadyToCleanup();
99+
List<Long> findCandidateIdsToCleanup(@Param("staleCleanupBefore") OffsetDateTime staleCleanupBefore);
100+
101+
// Atomic compare-and-set claim: marks a single not-yet-cleaned SENT row as cleanup-in-progress
102+
// by stamping cleanupStartTime, if it is claimable (never started, or abandoned by a crashed pod past the stale threshold).
103+
// Concurrent updates are serialized against the same row, so EXACTLY ONE caller gets returned 1.
104+
@Modifying(clearAutomatically = true, flushAutomatically = true)
105+
@Query("""
106+
update Email e
107+
set e.cleanupStartTime = :now
108+
where e.id = :id
109+
and e.attachmentsCleaned = false
110+
and e.state = it.aboutbits.springboot.emailservice.lib.EmailState.SENT
111+
and (
112+
e.cleanupStartTime is null
113+
or e.cleanupStartTime < :staleCleanupBefore
114+
)
115+
""")
116+
int claimForCleanup(
117+
@Param("id") long id,
118+
@Param("now") OffsetDateTime now,
119+
@Param("staleCleanupBefore") OffsetDateTime staleCleanupBefore
120+
);
94121
}

src/main/java/it/aboutbits/springboot/emailservice/lib/model/Email.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ public class Email {
5858
@Builder.Default
5959
private boolean attachmentsCleaned = false;
6060

61+
@Nullable
62+
private OffsetDateTime cleanupStartTime;
63+
6164
private OffsetDateTime scheduledAt;
6265
@Nullable
6366
private OffsetDateTime executionStartTime;

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@
3030
"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.",
3131
"defaultValue": "PT30M"
3232
},
33+
{
34+
"name": "aboutbits.emailservice.scheduling.stuck-cleanup-recovery-threshold",
35+
"type": "java.time.Duration",
36+
"description": "How long an email may keep its attachment-cleanup lock before the cleanup is considered abandoned (crashed pod) and eligible to be re-claimed. Must comfortably exceed the worst-case duration of releasing all attachments of a single email, otherwise a slow in-flight cleanup can be re-claimed by another pod and its attachments released twice (harmless only if `AttachmentDataSource.releaseAttachment` is idempotent).",
37+
"defaultValue": "PT30M"
38+
},
3339
{
3440
"name": "aboutbits.emailservice.scheduling.max-attempts",
3541
"type": "java.lang.Integer",

0 commit comments

Comments
 (0)