| 1 | package com.rbctcsworld.ecommerce.notification; | |
| 2 | ||
| 3 | import org.slf4j.Logger; | |
| 4 | import org.slf4j.LoggerFactory; | |
| 5 | import org.springframework.beans.factory.annotation.Value; | |
| 6 | import org.springframework.data.domain.PageRequest; | |
| 7 | import org.springframework.scheduling.annotation.Scheduled; | |
| 8 | import org.springframework.stereotype.Service; | |
| 9 | ||
| 10 | import java.time.Clock; | |
| 11 | import java.time.Duration; | |
| 12 | import java.time.LocalDateTime; | |
| 13 | ||
| 14 | /** | |
| 15 | * Sends due outbox rows. Every few seconds (app.notifications.poll-ms) on every backend instance: | |
| 16 | * | |
| 17 | * 1. read due rows (oldest first, a batch at a time) | |
| 18 | * 2. claim each one with a conditional UPDATE - two instances never send the same row | |
| 19 | * 3. send; success -> SENT, failure -> try again after 30 s, 1, 2, 4 minutes; the 5th failure -> FAILED | |
| 20 | * | |
| 21 | * At-least-once: an instance that dies after the mail server accepted the message but before "SENT" was | |
| 22 | * written leaves a SENDING row whose lease expires, and the e-mail is sent again. Losing an order | |
| 23 | * confirmation is worse than a rare duplicate. | |
| 24 | */ | |
| 25 | @Service | |
| 26 | public class NotificationDeliveryService { | |
| 27 | ||
| 28 | static final String REDACTED_CODE = "[sign-in code removed after delivery]"; | |
| 29 | static final String REDACTED = "[password reset link removed after delivery]"; | |
| 30 | ||
| 31 | private static final Logger log = LoggerFactory.getLogger(NotificationDeliveryService.class); | |
| 32 | ||
| 33 | static final int MAX_ATTEMPTS = 5; | |
| 34 | static final Duration LEASE = Duration.ofMinutes(2); | |
| 35 | ||
| 36 | private final NotificationRepository notifications; | |
| 37 | private final MailChannel channel; | |
| 38 | private final Clock clock; | |
| 39 | private final int batchSize; | |
| 40 | ||
| 41 | public NotificationDeliveryService(NotificationRepository notifications, MailChannel channel, Clock clock, | |
| 42 | @Value("${app.notifications.batch-size:20}") int batchSize) { | |
| 43 | this.notifications = notifications; | |
| 44 | this.channel = channel; | |
| 45 | this.clock = clock; | |
| 46 | this.batchSize = batchSize; | |
| 47 | } | |
| 48 | ||
| 49 | @Scheduled(fixedDelayString = "${app.notifications.poll-ms:2000}", initialDelayString = "${app.notifications.poll-ms:2000}") | |
| 50 | public void scheduled() { | |
| 51 | sendDue(); | |
| 52 | } | |
| 53 | ||
| 54 | /** Returns how many e-mails were sent in this round. */ | |
| 55 | public int sendDue() { | |
| 56 | LocalDateTime now = LocalDateTime.now(clock); | |
| 57 | int sent = 0; | |
| 58 | for (Notification n : notifications.findDue(now, PageRequest.of(0, batchSize))) { | |
| 59 |
1
1. sendDue : removed conditional - replaced equality check with false → KILLED |
if (notifications.claim(n.getId(), n.getNextAttemptAt(), now.plus(LEASE)) == 0) { |
| 60 | continue; // another instance took it | |
| 61 | } | |
| 62 | try { | |
| 63 |
1
1. sendDue : removed call to com/rbctcsworld/ecommerce/notification/MailChannel::send → KILLED |
channel.send(n.getRecipient(), n.getSubject(), n.getBody()); |
| 64 | notifications.markSent(n.getId(), LocalDateTime.now(clock)); | |
| 65 |
1
1. sendDue : removed conditional - replaced equality check with false → KILLED |
if (AccountEmailEvent.Type.PASSWORD_RESET.name().equals(n.getType())) { |
| 66 | // the link would let anyone with database access take over the account until it expires | |
| 67 | notifications.redactBody(n.getId(), REDACTED); | |
| 68 |
1
1. sendDue : removed conditional - replaced equality check with false → SURVIVED |
} else if (AccountEmailEvent.Type.LOGIN_CODE.name().equals(n.getType())) { |
| 69 | notifications.redactBody(n.getId(), REDACTED_CODE); | |
| 70 | } | |
| 71 |
1
1. sendDue : Changed increment from 1 to -1 → KILLED |
sent++; |
| 72 | } catch (RuntimeException e) { | |
| 73 |
1
1. sendDue : removed call to com/rbctcsworld/ecommerce/notification/NotificationDeliveryService::failed → KILLED |
failed(n, e); |
| 74 | } | |
| 75 | } | |
| 76 |
1
1. sendDue : replaced int return with 0 for com/rbctcsworld/ecommerce/notification/NotificationDeliveryService::sendDue → KILLED |
return sent; |
| 77 | } | |
| 78 | ||
| 79 | private void failed(Notification n, RuntimeException e) { | |
| 80 |
1
1. failed : Replaced integer addition with subtraction → KILLED |
int attempts = n.getAttempts() + 1; |
| 81 | String error = shorten(e.toString()); | |
| 82 |
2
1. failed : removed conditional - replaced comparison check with false → KILLED 2. failed : changed conditional boundary → KILLED |
if (attempts >= MAX_ATTEMPTS) { |
| 83 | notifications.markFailedAttempt(n.getId(), Notification.FAILED, attempts, LocalDateTime.now(clock), error); | |
| 84 | log.error("Notification {} ({}) gave up after {} attempts: {}", n.getId(), n.getType(), attempts, error); | |
| 85 | } else { | |
| 86 | notifications.markFailedAttempt(n.getId(), Notification.PENDING, attempts, | |
| 87 | LocalDateTime.now(clock).plus(backoff(attempts)), error); | |
| 88 | log.warn("Notification {} ({}) attempt {} failed, retry in {}: {}", n.getId(), n.getType(), attempts, | |
| 89 | backoff(attempts), error); | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | /** 30 s after the 1st failure, then 1, 2, 4 minutes. */ | |
| 94 | static Duration backoff(int attempts) { | |
| 95 |
3
1. backoff : Replaced integer subtraction with addition → KILLED 2. backoff : Replaced Shift Left with Shift Right → KILLED 3. backoff : replaced return value with null for com/rbctcsworld/ecommerce/notification/NotificationDeliveryService::backoff → KILLED |
return Duration.ofSeconds(30L << (attempts - 1)); |
| 96 | } | |
| 97 | ||
| 98 | static String shorten(String text) { | |
| 99 |
3
1. shorten : changed conditional boundary → KILLED 2. shorten : removed conditional - replaced comparison check with false → KILLED 3. shorten : replaced return value with "" for com/rbctcsworld/ecommerce/notification/NotificationDeliveryService::shorten → KILLED |
return text.length() <= 300 ? text : text.substring(0, 297) + "..."; |
| 100 | } | |
| 101 | } | |
Mutations | ||
| 59 |
1.1 |
|
| 63 |
1.1 |
|
| 65 |
1.1 |
|
| 68 |
1.1 |
|
| 71 |
1.1 |
|
| 73 |
1.1 |
|
| 76 |
1.1 |
|
| 80 |
1.1 |
|
| 82 |
1.1 2.2 |
|
| 95 |
1.1 2.2 3.3 |
|
| 99 |
1.1 2.2 3.3 |