Skip to content

Commit db84d0a

Browse files
authored
Merge pull request #88 from Today-s-Sound/docs/notification-delivery-comments
docs: 알림 발송 Outbox 동시성 주석 보강
2 parents 503b468 + 2018d0e commit db84d0a

10 files changed

Lines changed: 39 additions & 0 deletions

File tree

src/main/java/com/todaysound/todaysound_server/domain/alarm/entity/NotificationDelivery.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ public void claim(LocalDateTime claimedLeaseUntil) {
9797
this.leaseUntil = truncateToMicros(claimedLeaseUntil);
9898
}
9999

100+
/**
101+
* 재선점 후 도착한 이전 워커의 응답이 현재 작업 상태를 덮지 못하도록 lease를 비교한다.
102+
*/
100103
public boolean isClaimedWith(LocalDateTime claimedLeaseUntil) {
101104
return status == DeliveryStatus.PROCESSING
102105
&& Objects.equals(leaseUntil, truncateToMicros(claimedLeaseUntil));

src/main/java/com/todaysound/todaysound_server/domain/alarm/repository/NotificationDeliveryRepository.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414

1515
public interface NotificationDeliveryRepository extends JpaRepository<NotificationDelivery, Long> {
1616

17+
/**
18+
* 동시에 들어온 크롤러 콜백이 같은 event-token 작업을 생성하더라도
19+
* unique 충돌로 트랜잭션을 롤백하지 않고 기존 작업을 유지한다.
20+
*/
1721
@Modifying(flushAutomatically = true)
1822
@Query(value = """
1923
INSERT INTO notification_deliveries (
@@ -42,6 +46,10 @@ int insertPendingIfAbsent(
4246
@Param("createdAt") LocalDateTime createdAt
4347
);
4448

49+
/**
50+
* 다른 워커가 잠근 행은 기다리지 않고 건너뛰며, lease가 만료된 작업은 다시 선점한다.
51+
* 반환된 행의 잠금은 호출한 claimBatch() 트랜잭션이 끝날 때까지 유지된다.
52+
*/
4553
@Query(value = """
4654
SELECT delivery.id
4755
FROM notification_deliveries delivery
@@ -77,6 +85,9 @@ List<Long> findEligibleIdsForUpdate(
7785
""")
7886
List<NotificationDelivery> findAllForDispatchByIdIn(@Param("ids") Collection<Long> ids);
7987

88+
/**
89+
* 결과 반영과 재선점이 교차하지 않도록 행을 잠가 lease 확인과 상태 변경을 직렬화한다.
90+
*/
8091
@Lock(LockModeType.PESSIMISTIC_WRITE)
8192
@Query("""
8293
SELECT delivery

src/main/java/com/todaysound/todaysound_server/domain/alarm/service/InternalAlertService.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public class InternalAlertService {
3131

3232
@Transactional
3333
public void createAlert(InternalAlertCommand command) {
34+
// 동일 구독의 중복 콜백을 직렬화해 Summary 검사와 Outbox 생성을 한 임계 구역에서 처리한다.
3435
Subscription subscription = subscriptionRepository.findByIdForUpdate(command.subscriptionId())
3536
.orElseThrow(() -> BaseException.type(CommonErrorCode.ENTITY_NOT_FOUND));
3637

@@ -61,6 +62,7 @@ public void createAlert(InternalAlertCommand command) {
6162
return;
6263
}
6364

65+
// 구독이 달라도 같은 URL의 같은 게시글은 하나의 이벤트로 식별해 토큰별 중복 작업을 막는다.
6466
String eventId = CryptoUtils.sha256(
6567
subscription.getUrl().getId() + ":" + command.sitePostId());
6668
LocalDateTime now = LocalDateTime.now();

src/main/java/com/todaysound/todaysound_server/domain/alarm/service/NotificationDeliveryDispatcher.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
import lombok.extern.slf4j.Slf4j;
1919
import org.springframework.stereotype.Component;
2020

21+
/**
22+
* 선점 트랜잭션을 끝낸 뒤 FCM을 호출하고, 결과는 별도 트랜잭션으로 저장한다.
23+
* 외부 호출 중에는 DB 잠금과 커넥션을 점유하지 않는다. FCM 성공 직후 프로세스가 종료되면
24+
* lease 만료 후 재발송될 수 있으므로 전체 전달 보장은 exactly-once가 아닌 at-least-once다.
25+
*/
2126
@Slf4j
2227
@Component
2328
@RequiredArgsConstructor
@@ -35,6 +40,7 @@ public int dispatchPendingDeliveries() {
3540
return 0;
3641
}
3742

43+
// 하나의 Multicast는 payload를 공유하므로 메시지 내용과 eventId가 모두 같은 작업만 묶는다.
3844
Map<MessageKey, List<ClaimedDelivery>> groups = claimed.stream()
3945
.collect(Collectors.groupingBy(
4046
delivery -> new MessageKey(

src/main/java/com/todaysound/todaysound_server/domain/alarm/service/NotificationDeliveryRetryPolicy.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Duration delay(
3939
if (retryAfter == null || retryAfter.isNegative()) {
4040
return backoff;
4141
}
42+
// 서버 백오프보다 FCM Retry-After가 길면 공급자가 요구한 최소 대기 시간을 우선한다.
4243
return retryAfter.compareTo(backoff) > 0 ? retryAfter : backoff;
4344
}
4445

src/main/java/com/todaysound/todaysound_server/domain/alarm/service/NotificationDeliveryService.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
@RequiredArgsConstructor
2828
public class NotificationDeliveryService {
2929

30+
// 최초 전송 1회와 최대 3회의 재시도를 합한 횟수다.
3031
public static final int MAX_ATTEMPTS = 4;
3132
public static final int MAX_BATCH_SIZE = 500;
3233
private static final Duration LEASE_DURATION = Duration.ofMinutes(5);
@@ -46,6 +47,7 @@ public List<ClaimedDelivery> claimBatch(int requestedBatchSize) {
4647
public List<ClaimedDelivery> claimBatch(int requestedBatchSize, LocalDateTime requestedAt) {
4748
int batchSize = Math.max(1, Math.min(requestedBatchSize, MAX_BATCH_SIZE));
4849
LocalDateTime now = truncateToMicros(requestedAt);
50+
// 후보 행 잠금부터 PROCESSING 전환까지 한 트랜잭션으로 묶어 선점을 원자적으로 만든다.
4951
List<Long> eligibleIds = deliveryRepository.findEligibleIdsForUpdate(now, batchSize);
5052
if (eligibleIds.isEmpty()) {
5153
return List.of();
@@ -118,6 +120,7 @@ public void applyResults(Collection<DeliveryResult> results, LocalDateTime compl
118120
(first, ignored) -> first,
119121
LinkedHashMap::new
120122
));
123+
// 행 잠금 아래에서 lease를 검사해야 늦은 결과와 만료 작업의 재선점이 서로 덮어쓰지 않는다.
121124
List<NotificationDelivery> processingDeliveries = deliveryRepository.findAllByStatusAndIdIn(
122125
DeliveryStatus.PROCESSING,
123126
resultByDeliveryId.keySet()
@@ -145,6 +148,7 @@ private void applyResult(NotificationDelivery delivery, DeliveryResult result, L
145148
String errorCode = normalizedErrorCode(result.errorCode());
146149
if (result.unregistered()) {
147150
delivery.markFailed(errorCode);
151+
// 발송 중 토큰이 갱신됐을 수 있으므로 실제 시도한 토큰과 현재 값이 같을 때만 끈다.
148152
int deactivatedCount = fcmRepository.deactivateIfTokenMatches(
149153
delivery.getFcmToken().getId(),
150154
result.attemptedToken(),

src/main/java/com/todaysound/todaysound_server/domain/summary/infra/scheduler/SummaryCleanupScheduler.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
@RequiredArgsConstructor
1919
public class SummaryCleanupScheduler {
2020

21+
// Summary의 cascade 삭제로 미완료 발송 작업이 사라지지 않도록 정리 대상에서 제외한다.
2122
private static final Set<DeliveryStatus> IN_FLIGHT_DELIVERY_STATUSES = Set.of(
2223
DeliveryStatus.PENDING,
2324
DeliveryStatus.PROCESSING,

src/main/java/com/todaysound/todaysound_server/domain/user/repository/FCMRepository.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ public interface FCMRepository extends JpaRepository<FCM_Token, Long> {
1616

1717
FCM_Token findByUserId(Long userId);
1818

19+
/**
20+
* 발송 당시 토큰과 현재 토큰이 같을 때만 비활성화해,
21+
* 늦은 UNREGISTERED 응답이 이미 갱신된 토큰을 끄지 않게 한다.
22+
*/
1923
@Modifying
2024
@Query("""
2125
UPDATE FCM_Token token

src/main/java/com/todaysound/todaysound_server/global/application/FCMService.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,13 +118,15 @@ public List<FcmSendResult> sendMulticast(
118118

119119
ApnsConfig apnsConfig = ApnsConfig.builder()
120120
.putHeader("apns-priority", "10")
121+
// APNs에 대기 중인 동일 이벤트의 병합을 요청하며 이미 표시된 알림까지 제거하지는 않는다.
121122
.putHeader("apns-collapse-id", eventId)
122123
.setAps(Aps.builder().setSound("default").setBadge(1).build())
123124
.build();
124125

125126
MulticastMessage message = MulticastMessage.builder()
126127
.setNotification(notification)
127128
.setApnsConfig(apnsConfig)
129+
// 클라이언트가 eventId를 기준으로 중복 표시를 방지할 수 있도록 함께 전달한다.
128130
.putData("eventId", eventId)
129131
.addAllTokens(targets.stream().map(FcmTarget::token).toList())
130132
.build();
@@ -158,6 +160,7 @@ private List<FcmSendResult> mapResponse(BatchResponse response, List<FcmTarget>
158160
List<SendResponse> responses = response == null ? null : response.getResponses();
159161
List<FcmSendResult> results = new ArrayList<>(targets.size());
160162

163+
// Admin SDK가 입력 토큰과 응답 순서를 보존하므로 같은 index의 발송 건에 결과를 대응한다.
161164
for (int index = 0; index < targets.size(); index++) {
162165
FcmTarget target = targets.get(index);
163166
if (responses == null || index >= responses.size() || responses.get(index) == null) {
@@ -237,6 +240,7 @@ private FcmSendResult failureResult(FcmTarget target, FirebaseMessagingException
237240
);
238241
}
239242

243+
/** Retry-After의 delta-seconds와 HTTP-date 형식을 모두 지연 시간으로 변환한다. */
240244
private Duration retryAfterOf(FirebaseMessagingException exception) {
241245
if (exception.getHttpResponse() == null) {
242246
return null;

src/main/java/com/todaysound/todaysound_server/global/application/FcmTokenLifecycleService.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ public class FcmTokenLifecycleService {
1414

1515
private final FCMRepository fcmRepository;
1616

17+
/**
18+
* 트랜잭션 없이 실행되는 직접 발송 경로에서도 토큰 무효화만 독립적으로 커밋한다.
19+
*/
1720
@Transactional(propagation = Propagation.REQUIRES_NEW)
1821
public void deactivateAllIfTokenMatches(Collection<FcmTarget> attemptedTokens) {
1922
if (attemptedTokens.isEmpty()) {

0 commit comments

Comments
 (0)