Skip to content

Commit f2615ea

Browse files
committed
requested changes
1 parent 5e999cc commit f2615ea

10 files changed

Lines changed: 109 additions & 34 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: 6 additions & 4 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,13 +30,14 @@ public class CleanupAttachmentFiles {
2930
void cleanupAttachments() {
3031
logStartOfPass();
3132

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

3436
var countClaimed = 0;
3537
var countCleaned = 0;
3638
var countError = 0;
3739
for (var id : candidateIds) {
38-
var claimed = manageEmail.tryClaimForCleanup(id);
40+
var claimed = manageEmail.tryClaimForCleanup(id, staleCleanupBefore);
3941

4042
if (claimed.isEmpty()) {
4143
// Lost race to another pod; Skip
@@ -46,7 +48,7 @@ void cleanupAttachments() {
4648
try {
4749
manageEmail.completeClaimedCleanup(claimed.get());
4850
countCleaned++;
49-
} catch (AttachmentException _) {
51+
} catch (Exception _) {
5052
countError++;
5153
}
5254
}

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: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -142,25 +142,20 @@ Email completeClaimedSend(Email email) {
142142
return transactionTemplate.execute(_ -> emailRepository.save(email));
143143
}
144144

145-
// Atomically flips a not-yet-cleaned SENT row to cleaned
145+
// Atomically marks a not-yet-cleaned SENT row as cleanup-in-progress
146146
@Transactional
147-
Optional<Email> tryClaimForCleanup(long id) {
148-
var claimed = emailRepository.claimForCleanup(id);
147+
Optional<Email> tryClaimForCleanup(long id, OffsetDateTime staleCleanupBefore) {
148+
var claimed = emailRepository.claimForCleanup(id, OffsetDateTime.now(), staleCleanupBefore);
149149
return claimed == 0 ? Optional.empty() : emailRepository.findById(id);
150150
}
151151

152-
// Actually release the attachment payloads of the claimed email
152+
// Actually release the attachment payloads of the claimed email.
153153
void completeClaimedCleanup(final Email email) throws AttachmentException {
154-
try {
155-
for (var attachment : email.getAttachments()) {
156-
attachmentDataSource.releaseAttachment(attachment.getFileReference());
157-
}
158-
} catch (AttachmentException e) {
159-
// Undo the claim so the row is retried on a later pass
160-
email.setAttachmentsCleaned(false);
161-
transactionTemplate.execute(_ -> emailRepository.save(email));
162-
throw e;
154+
for (var attachment : email.getAttachments()) {
155+
attachmentDataSource.releaseAttachment(attachment.getFileReference());
163156
}
157+
email.setAttachmentsCleaned(true);
158+
transactionTemplate.execute(_ -> emailRepository.save(email));
164159
}
165160

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

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ List<Long> candidateIdsToSend(OffsetDateTime staleSendingBefore) {
4646
);
4747
}
4848

49-
List<Long> candidateIdsToCleanup() {
50-
return emailRepository.findCandidateIdsToCleanup();
49+
List<Long> candidateIdsToCleanup(OffsetDateTime staleCleanupBefore) {
50+
return emailRepository.findCandidateIdsToCleanup(staleCleanupBefore);
5151
}
5252

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

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

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,22 +86,36 @@ int claimForSend(
8686

8787
// Plain read, no locking -> two pods may see overlapping candidate sets.
8888
// The atomic UPDATE in claimForCleanup arbitrates the actual claim.
89+
// Includes rows whose cleanup was abandoned by a crashed pod (past the stale threshold).
8990
@Query("""
9091
select e.id from Email e
9192
where e.attachmentsCleaned = false
9293
and e.state = it.aboutbits.springboot.emailservice.lib.EmailState.SENT
94+
and (
95+
e.cleanupStartTime is null
96+
or e.cleanupStartTime < :staleCleanupBefore
97+
)
9398
""")
94-
List<Long> findCandidateIdsToCleanup();
99+
List<Long> findCandidateIdsToCleanup(@Param("staleCleanupBefore") OffsetDateTime staleCleanupBefore);
95100

96-
// Atomic compare-and-set claim: flips a single not-yet-cleaned SENT row to cleaned.
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).
97103
// Concurrent updates are serialized against the same row, so EXACTLY ONE caller gets returned 1.
98104
@Modifying(clearAutomatically = true, flushAutomatically = true)
99105
@Query("""
100106
update Email e
101-
set e.attachmentsCleaned = true
107+
set e.cleanupStartTime = :now
102108
where e.id = :id
103109
and e.attachmentsCleaned = false
104110
and e.state = it.aboutbits.springboot.emailservice.lib.EmailState.SENT
111+
and (
112+
e.cleanupStartTime is null
113+
or e.cleanupStartTime < :staleCleanupBefore
114+
)
105115
""")
106-
int claimForCleanup(@Param("id") long id);
116+
int claimForCleanup(
117+
@Param("id") long id,
118+
@Param("now") OffsetDateTime now,
119+
@Param("staleCleanupBefore") OffsetDateTime staleCleanupBefore
120+
);
107121
}

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",

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

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package it.aboutbits.springboot.emailservice.lib.application;
22

3+
import it.aboutbits.springboot.emailservice.lib.AttachmentCleanerCallback;
34
import it.aboutbits.springboot.emailservice.lib.AttachmentDataSource;
45
import it.aboutbits.springboot.emailservice.lib.EmailState;
56
import it.aboutbits.springboot.emailservice.lib.exception.AttachmentException;
@@ -9,11 +10,14 @@
910
import it.aboutbits.springboot.emailservice.support.database.WithPostgres;
1011
import it.aboutbits.springboot.emailservice.support.database.factory.EmailFactory;
1112
import org.jspecify.annotations.NullMarked;
13+
import org.jspecify.annotations.Nullable;
1214
import org.junit.jupiter.api.Test;
1315
import org.springframework.beans.factory.annotation.Autowired;
1416
import org.springframework.boot.test.context.SpringBootTest;
1517
import org.springframework.test.context.bean.override.mockito.MockitoBean;
1618

19+
import java.time.OffsetDateTime;
20+
import java.time.temporal.ChronoUnit;
1721
import java.util.HashSet;
1822
import java.util.Set;
1923
import java.util.concurrent.CountDownLatch;
@@ -28,6 +32,7 @@
2832
import static org.mockito.Mockito.verify;
2933

3034
@SpringBootTest(properties = {
35+
"aboutbits.emailservice.scheduling.stuck-cleanup-recovery-threshold=PT5M",
3136
"aboutbits.emailservice.scheduling.interval=30000"
3237
})
3338
@WithPostgres
@@ -36,6 +41,9 @@ class CleanupAttachmentFilesTest {
3641
@MockitoBean
3742
AttachmentDataSource attachmentDataSource;
3843

44+
@MockitoBean
45+
AttachmentCleanerCallback attachmentCleanerCallback;
46+
3947
@Autowired
4048
EmailRepository emailRepository;
4149

@@ -56,14 +64,15 @@ void givenSentUncleanedEmails_cleanupAttachments_shouldReleaseAndMarkCleaned() t
5664
verify(attachmentDataSource, times(1)).releaseAttachment(100L);
5765
verify(attachmentDataSource, times(1)).releaseAttachment(101L);
5866
verify(attachmentDataSource, times(1)).releaseAttachment(102L);
67+
verify(attachmentCleanerCallback).report(new AttachmentCleanerCallback.Report(3, 3, 0));
5968
}
6069

6170
@Test
6271
void givenNonSentOrAlreadyCleanedRows_cleanupAttachments_shouldSkip() throws AttachmentException {
63-
persistEmail(200L, EmailState.PENDING, false);
64-
persistEmail(201L, EmailState.SENDING, false);
65-
persistEmail(202L, EmailState.ERROR, false);
66-
persistEmail(203L, EmailState.SENT, true);
72+
persistEmail(200L, EmailState.PENDING, false, null);
73+
persistEmail(201L, EmailState.SENDING, false, null);
74+
persistEmail(202L, EmailState.ERROR, false, null);
75+
persistEmail(203L, EmailState.SENT, true, null);
6776

6877
cleanupAttachmentFiles.cleanupAttachments();
6978

@@ -74,15 +83,50 @@ void givenNonSentOrAlreadyCleanedRows_cleanupAttachments_shouldSkip() throws Att
7483
}
7584

7685
@Test
77-
void givenReleaseFails_cleanupAttachments_shouldResetFlagForRetry() throws AttachmentException {
86+
void givenReleaseFails_cleanupAttachments_shouldLeaveClaimForStaleRecovery() throws AttachmentException {
7887
var email = persistCleanableEmail(300L);
7988
doThrow(new AttachmentException()).when(attachmentDataSource).releaseAttachment(300L);
8089

8190
cleanupAttachmentFiles.cleanupAttachments();
8291

92+
// Files were not released, and the claim is left in place so a later pass recovers it
93+
assertThat(emailRepository.findById(email.getId()))
94+
.get()
95+
.satisfies(reloaded -> {
96+
assertThat(reloaded.isAttachmentsCleaned()).isFalse();
97+
assertThat(reloaded.getCleanupStartTime()).isNotNull();
98+
});
99+
verify(attachmentCleanerCallback).report(new AttachmentCleanerCallback.Report(1, 0, 1));
100+
}
101+
102+
@Test
103+
void givenStuckCleanup_cleanupAttachments_shouldRecoverAndClean() throws AttachmentException {
104+
var staleStart = OffsetDateTime.now().minusMinutes(10);
105+
var email = persistEmail(400L, EmailState.SENT, false, staleStart);
106+
107+
cleanupAttachmentFiles.cleanupAttachments();
108+
83109
assertThat(emailRepository.findById(email.getId()))
84110
.get()
85-
.satisfies(reloaded -> assertThat(reloaded.isAttachmentsCleaned()).isFalse());
111+
.satisfies(reloaded -> assertThat(reloaded.isAttachmentsCleaned()).isTrue());
112+
verify(attachmentDataSource, times(1)).releaseAttachment(400L);
113+
}
114+
115+
@Test
116+
void givenFreshCleanupInProgress_cleanupAttachments_shouldNotStealFromOtherPod() throws AttachmentException {
117+
// cleanupStartTime within the 5-minute threshold -> another pod is cleaning this right now -> do not re-claim
118+
var recentStart = OffsetDateTime.now().minusSeconds(30).truncatedTo(ChronoUnit.MICROS);
119+
var email = persistEmail(500L, EmailState.SENT, false, recentStart);
120+
121+
cleanupAttachmentFiles.cleanupAttachments();
122+
123+
assertThat(emailRepository.findById(email.getId()))
124+
.get()
125+
.satisfies(reloaded -> {
126+
assertThat(reloaded.isAttachmentsCleaned()).isFalse();
127+
assertThat(reloaded.getCleanupStartTime()).isEqualTo(recentStart);
128+
});
129+
verify(attachmentDataSource, times(0)).releaseAttachment(anyLong());
86130
}
87131

88132
@Test
@@ -121,13 +165,19 @@ void givenConcurrentPasses_cleanupAttachments_shouldReleaseEachEmailExactlyOnce(
121165
}
122166

123167
private Email persistCleanableEmail(long fileReference) {
124-
return persistEmail(fileReference, EmailState.SENT, false);
168+
return persistEmail(fileReference, EmailState.SENT, false, null);
125169
}
126170

127-
private Email persistEmail(long fileReference, EmailState state, boolean attachmentsCleaned) {
171+
private Email persistEmail(
172+
long fileReference,
173+
EmailState state,
174+
boolean attachmentsCleaned,
175+
@Nullable OffsetDateTime cleanupStartTime
176+
) {
128177
var email = EmailFactory.once()
129178
.state(state)
130179
.attachmentsCleaned(attachmentsCleaned)
180+
.cleanupStartTime(cleanupStartTime)
131181
.build();
132182

133183
var attachment = new EmailAttachment();

0 commit comments

Comments
 (0)