22
33
44import it .aboutbits .springboot .emailservice .lib .EmailSchedulerCallback ;
5+ import it .aboutbits .springboot .emailservice .lib .model .Email ;
56import lombok .extern .log4j .Log4j2 ;
67import org .jspecify .annotations .NullMarked ;
78import org .springframework .scheduling .annotation .Scheduled ;
89
910import java .time .Duration ;
1011import java .time .OffsetDateTime ;
1112import java .util .List ;
13+ import java .util .Optional ;
1214
1315@ Log4j2
1416@ NullMarked
@@ -43,21 +45,55 @@ void sendEmails() {
4345 var staleSendingBefore = OffsetDateTime .now ().minus (stuckSendingRecoveryThreshold );
4446 var candidateIds = queryEmail .candidateIdsToSend (staleSendingBefore );
4547
48+ var countClaimed = 0 ;
4649 var countSent = 0 ;
4750 var countError = 0 ;
4851 for (var id : candidateIds ) {
49- var claimed = manageEmail .tryClaimForSend (id , staleSendingBefore );
52+ Optional <Email > claimed ;
53+ try {
54+ claimed = manageEmail .tryClaimForSend (id , staleSendingBefore );
55+ } catch (Exception e ) {
56+ // Claim failed and rolled back: nothing was sent and the row is unchanged, so it stays claimable
57+ // Another pod may pick it up in this same pass, or it is retried on a later pass.
58+ log .error (
59+ JOB_DESCRIPTION + " | Failed to claim email for sending: {}. "
60+ + "Nothing was sent and the row is unchanged; it stays claimable and may be picked up "
61+ + "by another pod in this cycle or retried on a later pass." ,
62+ id ,
63+ e
64+ );
65+ continue ;
66+ }
67+
5068 if (claimed .isEmpty ()) {
5169 // Lost race to another pod; Skip
5270 continue ;
5371 }
54- var updated = manageEmail .completeClaimedSend (claimed .get ());
55- switch (updated .getState ()) {
56- case ERROR , PENDING -> countError ++;
57- case SENT -> countSent ++;
58- default -> log .warn (
59- JOB_DESCRIPTION + " | Job produced an invalid notification result state: {}." ,
60- updated .getState ().name ()
72+ countClaimed ++;
73+
74+ try {
75+ var updated = manageEmail .completeClaimedSend (claimed .get ());
76+ switch (updated .getState ()) {
77+ // We do not count PENDING emails as errors since they will
78+ // be retried and eventually end up in one of the two buckets.
79+ case ERROR -> countError ++;
80+ case SENT -> countSent ++;
81+ default -> log .warn (
82+ JOB_DESCRIPTION + " | Job produced an invalid notification result state: {}." ,
83+ updated .getState ().name ()
84+ );
85+ }
86+ } catch (Exception e ) {
87+ // Best-effort visibility only: we cannot reliably recover here. The email may already have been
88+ // sent but persisting the result failed, so the row stays in SENDING and will be re-claimed and
89+ // re-sent after the recovery threshold (possible duplicate delivery).
90+ countError ++;
91+ log .error (
92+ JOB_DESCRIPTION + " | Failed to complete send for email: {}. It may have been sent already; "
93+ + "if the result could not be persisted the row stays in SENDING and will be re-sent "
94+ + "after the recovery threshold (possible duplicate delivery)." ,
95+ id ,
96+ e
6197 );
6298 }
6399 }
@@ -66,7 +102,7 @@ void sendEmails() {
66102
67103 for (var callback : callbacks ) {
68104 callback .report (new EmailSchedulerCallback .Report (
69- candidateIds . size () ,
105+ countClaimed ,
70106 countSent ,
71107 countError
72108 ));
0 commit comments