NotificationDeliveryService.java

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
Location : sendDue
Killed by : com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest]/[method:aRowAnotherInstanceClaimedIsLeftAlone()]
removed conditional - replaced equality check with false → KILLED

63

1.1
Location : sendDue
Killed by : com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest]/[method:aDeliveredResetLinkIsRemovedFromTheDatabase()]
removed call to com/rbctcsworld/ecommerce/notification/MailChannel::send → KILLED

65

1.1
Location : sendDue
Killed by : com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest]/[method:aDeliveredResetLinkIsRemovedFromTheDatabase()]
removed conditional - replaced equality check with false → KILLED

68

1.1
Location : sendDue
Killed by : none
removed conditional - replaced equality check with false → SURVIVED
Covering tests

71

1.1
Location : sendDue
Killed by : com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest]/[method:claimedRowIsSentAndMarkedSent()]
Changed increment from 1 to -1 → KILLED

73

1.1
Location : sendDue
Killed by : com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest]/[method:firstFailureRetriesAfterThirtySeconds()]
removed call to com/rbctcsworld/ecommerce/notification/NotificationDeliveryService::failed → KILLED

76

1.1
Location : sendDue
Killed by : com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest]/[method:claimedRowIsSentAndMarkedSent()]
replaced int return with 0 for com/rbctcsworld/ecommerce/notification/NotificationDeliveryService::sendDue → KILLED

80

1.1
Location : failed
Killed by : com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest]/[method:anUndeliveredResetLinkIsNotRedactedYet()]
Replaced integer addition with subtraction → KILLED

82

1.1
Location : failed
Killed by : com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest]/[method:fourthFailureWaitsFourMinutesFifthGivesUp()]
removed conditional - replaced comparison check with false → KILLED

2.2
Location : failed
Killed by : com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest]/[method:fourthFailureWaitsFourMinutesFifthGivesUp()]
changed conditional boundary → KILLED

95

1.1
Location : backoff
Killed by : com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest]/[method:backoffDoubles()]
Replaced integer subtraction with addition → KILLED

2.2
Location : backoff
Killed by : com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest]/[method:backoffDoubles()]
Replaced Shift Left with Shift Right → KILLED

3.3
Location : backoff
Killed by : com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest]/[method:backoffDoubles()]
replaced return value with null for com/rbctcsworld/ecommerce/notification/NotificationDeliveryService::backoff → KILLED

99

1.1
Location : shorten
Killed by : com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest]/[method:longErrorsAreShortenedForTheColumn()]
changed conditional boundary → KILLED

2.2
Location : shorten
Killed by : com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest]/[method:longErrorsAreShortenedForTheColumn()]
removed conditional - replaced comparison check with false → KILLED

3.3
Location : shorten
Killed by : com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest.[engine:junit-jupiter]/[class:com.rbctcsworld.ecommerce.notification.NotificationDeliveryServiceTest]/[method:longErrorsAreShortenedForTheColumn()]
replaced return value with "" for com/rbctcsworld/ecommerce/notification/NotificationDeliveryService::shorten → KILLED

Active mutators

Tests examined


Report generated by PIT 1.30.0 support