Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 101 additions & 4 deletions docs/task-async-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ info:
title: Pinit task events over RabbitMQ
version: '1.0.0'
description: 핀잇의 task 서버가 발행/구독 하는 이벤트 정리
x-notes: |
Task/Schedule 분리 이후로 일정 이벤트는 V1 스케줄(작업과 독립)에서도 발행됩니다.
Task 연동 여부와 무관하게 일정 상태 이벤트는 동일한 라우팅 키를 사용합니다.

servers:
rabbitmq:
Expand Down Expand Up @@ -78,6 +81,32 @@ channels:
type: direct
durable: true
autoDelete: false
task.completed:
address: task.completed
messages:
taskCompleted:
$ref: '#/components/messages/TaskCompleted'
bindings:
amqp:
is: routingKey
exchange:
name: task.task.direct
type: direct
durable: true
autoDelete: false
task.canceled:
address: task.canceled
messages:
taskCanceled:
$ref: '#/components/messages/TaskCanceled'
bindings:
amqp:
is: routingKey
exchange:
name: task.task.direct
type: direct
durable: true
autoDelete: false

operations:
publishScheduleTimeUpcomingUpdated: # 발행하는 방법의 정의, 여기서 발행을 처리함을 명시
Expand Down Expand Up @@ -160,6 +189,38 @@ operations:
messages:
- $ref: '#/channels/schedule.state.completed/messages/scheduleStateCompleted'

publishTaskCompleted:
action: send
channel:
$ref: '#/channels/task.completed'
summary: 작업 완료 이벤트 발행
messages:
- $ref: '#/channels/task.completed/messages/taskCompleted'

subscribeTaskCompleted:
action: receive
channel:
$ref: '#/channels/task.completed'
summary: 작업 완료 이벤트 구독
messages:
- $ref: '#/channels/task.completed/messages/taskCompleted'

publishTaskCanceled:
action: send
channel:
$ref: '#/channels/task.canceled'
summary: 작업 취소 이벤트 발행
messages:
- $ref: '#/channels/task.canceled/messages/taskCanceled'

subscribeTaskCanceled:
action: receive
channel:
$ref: '#/channels/task.canceled'
summary: 작업 취소 이벤트 구독
messages:
- $ref: '#/channels/task.canceled/messages/taskCanceled'

components:
messages:
ScheduleTimeUpcomingState:
Expand All @@ -176,10 +237,10 @@ components:
format: int64
newUpcomingTime:
type: string
format: date-time
format: designatedStartTime-time
occurredAt:
type: string
format: date-time
format: designatedStartTime-time
idempotentKey:
Comment on lines 238 to 244
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(1) 문제점: AsyncAPI 스키마의 string format을 표준 date-time에서 designatedStartTime-time로 변경했는데, 이 값은 OpenAPI/AsyncAPI tooling에서 일반적으로 인식되는 포맷이 아니고 문서 내에서도 별도 정의가 없습니다.
(2) 영향: 문서를 기반으로 한 검증/코드 생성/스키마 호환성 체크가 실패하거나, 소비자가 RFC3339(date-time)로 해석해야 하는 필드를 잘못 이해할 수 있습니다.
(3) 수정 제안: 실제 값이 RFC3339라면 format: date-time을 유지하고, 의미 차이는 description 또는 x-... 커스텀 필드로 보강해 주세요(커스텀 format을 쓰려면 문서 상단에 정의/설명을 추가).

Copilot uses AI. Check for mistakes.
type: string
required: [ ownerId, scheduleId, newUpcomingTime, occurredAt, idempotentKey ]
Expand All @@ -197,7 +258,7 @@ components:
format: int64
occurredAt:
type: string
format: date-time
format: designatedStartTime-time
idempotentKey:
type: string
required: [ ownerId, scheduleId, occurredAt, idempotentKey ]
Expand All @@ -218,7 +279,43 @@ components:
description: 상태 전환 전 상태
occurredAt:
type: string
format: date-time
format: designatedStartTime-time
idempotentKey:
type: string
required: [ ownerId, scheduleId, beforeState, occurredAt, idempotentKey ]
TaskCompleted:
name: TaskCompleted
title: 작업 완료 정보
payload:
type: object
properties:
ownerId:
type: integer
format: int64
taskId:
type: integer
format: int64
occurredAt:
type: string
format: designatedStartTime-time
idempotentKey:
type: string
required: [ ownerId, taskId, occurredAt, idempotentKey ]
TaskCanceled:
name: TaskCanceled
title: 작업 취소 정보(미완료로 되돌림 포함)
payload:
type: object
properties:
ownerId:
type: integer
format: int64
taskId:
type: integer
format: int64
occurredAt:
type: string
format: designatedStartTime-time
idempotentKey:
type: string
required: [ ownerId, taskId, occurredAt, idempotentKey ]
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ public ScheduleNotificationService(UpcomingScheduleNotificationRepository notifi
public void handleUpcomingUpdated(UpcomingUpdatedCommand command) {
notificationRepository.findByScheduleIdAndOwnerId(command.scheduleId(), command.ownerId())
.ifPresentOrElse(
existing -> existing.updateScheduleStartTime(resolveScheduleStartTime(command, existing), command.idempotentKey()),
existing -> notificationRepository.updateScheduleStartTimeAndIdempotentKey(
command.scheduleId(),
command.ownerId(),
resolveScheduleStartTime(command, existing),
command.idempotentKey()
),
Comment on lines +36 to +41
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(1) 문제점: find 후 update를 수행하는 TOCTOU 흐름이라, find 시점엔 존재했어도 update 시점에 0건 갱신될 수 있는데(동시 삭제/정리 작업 등), 현재는 그 경우를 복구하지 않습니다.
(2) 영향: ‘업데이트 이벤트’를 처리했음에도 DB에 반영되지 않아 알림 발송/중복제거(idempotentKey) 로직이 틀어질 수 있습니다.
(3) 수정 제안: update 메서드가 갱신 건수를 반환하도록 포트/어댑터를 맞춘 뒤(updatedRows), 0이면 save로 전환하거나(업서트) 최소한 예외 처리로 실패를 명시해 주세요. 가능하면 find 없이 update→0이면 insert로 DB round-trip도 줄일 수 있습니다.

Suggested change
existing -> notificationRepository.updateScheduleStartTimeAndIdempotentKey(
command.scheduleId(),
command.ownerId(),
resolveScheduleStartTime(command, existing),
command.idempotentKey()
),
existing -> {
int updatedRows = notificationRepository.updateScheduleStartTimeAndIdempotentKey(
command.scheduleId(),
command.ownerId(),
resolveScheduleStartTime(command, existing),
command.idempotentKey()
);
if (updatedRows == 0) {
// 동시 삭제 등으로 업데이트 대상이 사라진 경우, 신규로 생성하여 업서트 보장
notificationRepository.save(
buildNotification(
command.ownerId(),
command.scheduleId(),
command.idempotentKey(),
toStringValue(command.newUpcomingTime())
)
);
}
},

Copilot uses AI. Check for mistakes.
() -> notificationRepository.save(buildNotification(command.ownerId(), command.scheduleId(), command.idempotentKey(), toStringValue(command.newUpcomingTime())))
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ public interface UpcomingScheduleNotificationRepository {
Optional<UpcomingScheduleNotification> findByScheduleIdAndOwnerId(Long scheduleId, Long ownerId);
boolean existsByScheduleIdAndOwnerId(Long scheduleId, Long ownerId);
UpcomingScheduleNotification save(UpcomingScheduleNotification notification);

void updateScheduleStartTimeAndIdempotentKey(Long scheduleId, Long ownerId, String scheduleStartTime, String idempotentKey);
void deleteByScheduleIdAndOwnerId(Long scheduleId, Long ownerId);
void deleteAllInBatch(List<UpcomingScheduleNotification> notifications);
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,31 @@
package me.pinitnotification.infrastructure.persistence.notification;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Optional;
import java.util.UUID;

public interface UpcomingScheduleNotificationJpaRepository extends JpaRepository<UpcomingScheduleNotificationEntity, Long> {
public interface UpcomingScheduleNotificationJpaRepository extends JpaRepository<UpcomingScheduleNotificationEntity, UUID> {
Optional<UpcomingScheduleNotificationEntity> findByScheduleIdAndOwnerId(Long scheduleId, Long ownerId);
boolean existsByScheduleIdAndOwnerId(Long scheduleId, Long ownerId);

@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("""
UPDATE UpcomingScheduleNotificationEntity n
SET n.scheduleStartTime = :scheduleStartTime,
n.idempotentKey = :idempotentKey
WHERE n.scheduleId = :scheduleId
AND n.ownerId = :ownerId
""")
int updateScheduleStartTimeAndIdempotentKey(
@Param("scheduleId") Long scheduleId,
@Param("ownerId") Long ownerId,
@Param("scheduleStartTime") String scheduleStartTime,
@Param("idempotentKey") String idempotentKey
);

void deleteByScheduleIdAndOwnerId(Long scheduleId, Long ownerId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@

import me.pinitnotification.domain.notification.UpcomingScheduleNotification;
import me.pinitnotification.domain.notification.UpcomingScheduleNotificationRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public class UpcomingScheduleNotificationRepositoryAdapter implements UpcomingScheduleNotificationRepository {
private static final Logger log = LoggerFactory.getLogger(UpcomingScheduleNotificationRepositoryAdapter.class);

private final UpcomingScheduleNotificationJpaRepository jpaRepository;

public UpcomingScheduleNotificationRepositoryAdapter(UpcomingScheduleNotificationJpaRepository jpaRepository) {
Expand Down Expand Up @@ -39,6 +43,14 @@ public UpcomingScheduleNotification save(UpcomingScheduleNotification notificati
return toDomain(saved);
}

@Override
public void updateScheduleStartTimeAndIdempotentKey(Long scheduleId, Long ownerId, String scheduleStartTime, String idempotentKey) {
int updatedRows = jpaRepository.updateScheduleStartTimeAndIdempotentKey(scheduleId, ownerId, scheduleStartTime, idempotentKey);
if (updatedRows == 0) {
log.debug("Skip updating notification. scheduleId={}, ownerId={} not found", scheduleId, ownerId);
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(1) 문제점: update 쿼리가 0건 갱신되면(=대상이 없으면) debug 로그만 남기고 그대로 종료해서, 호출 측에서는 성공/실패를 구분할 수 없고 실제로는 갱신이 누락될 수 있습니다.
(2) 영향: 이벤트 처리 순서 뒤바뀜/동시 삭제 등으로 대상 레코드가 사라진 경우, 알림 정보가 최신 상태로 맞춰지지 않은 채 조용히 지나가 운영 중 추적이 어려워집니다.
(3) 수정 제안: updatedRows==0을 ‘정상 스킵’으로 처리할 게 아니라 (a) warn 로그로 격상하거나 (b) 예외를 던져 상위에서 재시도/보상 처리하게 하거나 (c) 필요 시 새로 save 하는 방식(업서트) 중 하나로 명확히 처리해 주세요.

Suggested change
log.debug("Skip updating notification. scheduleId={}, ownerId={} not found", scheduleId, ownerId);
log.warn("Skip updating notification because target not found. scheduleId={}, ownerId={}", scheduleId, ownerId);

Copilot uses AI. Check for mistakes.
}
}

@Override
public void deleteByScheduleIdAndOwnerId(Long scheduleId, Long ownerId) {
jpaRepository.deleteByScheduleIdAndOwnerId(scheduleId, ownerId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,12 @@ void handleUpcomingUpdated_updatesExistingNotification() {

scheduleNotificationService.handleUpcomingUpdated(command);

assertThat(existing.getScheduleStartTime()).isEqualTo("2024-02-01T12:00Z");
assertThat(existing.getIdempotentKey()).isEqualTo("new-key");
verify(notificationRepository).updateScheduleStartTimeAndIdempotentKey(
scheduleId,
ownerId,
"2024-02-01T12:00Z",
"new-key"
);
verify(notificationRepository, never()).save(any());
verifyNoInteractions(scheduleQueryPort);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,31 @@ void savesAndFindsByScheduleAndOwner() {
assertThat(loaded).isPresent();
assertThat(loaded.get().getPublicId()).isNotNull();
}

@Test
void updatesScheduleStartTimeAndIdempotentKey() {
UpcomingScheduleNotificationEntity entity = new UpcomingScheduleNotificationEntity();
entity.setOwnerId(10L);
entity.setScheduleId(20L);
entity.setScheduleTitle("title");
entity.setScheduleStartTime("2025-01-01T00:00:00Z");
entity.setIdempotentKey("key-1");
repository.save(entity);

int updatedRows = repository.updateScheduleStartTimeAndIdempotentKey(
20L,
10L,
"2025-01-03T10:30:00Z",
"key-2"
);

assertThat(updatedRows).isEqualTo(1);

Optional<UpcomingScheduleNotificationEntity> loaded =
repository.findByScheduleIdAndOwnerId(20L, 10L);

assertThat(loaded).isPresent();
assertThat(loaded.get().getScheduleStartTime()).isEqualTo("2025-01-03T10:30:00Z");
assertThat(loaded.get().getIdempotentKey()).isEqualTo("key-2");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,26 @@ void savesAndLoadsDomainWithPublicId() {
assertThat(loaded).isPresent();
assertThat(loaded.get().getId()).isEqualTo(publicId);
}

@Test
void updatesScheduleStartTimeAndIdempotentKey() {
UUID publicId = UUID.randomUUID();
repository.save(new UpcomingScheduleNotification(
publicId,
1L,
2L,
"title",
"2025-01-01T00:00:00Z",
"key-1"
));

repository.updateScheduleStartTimeAndIdempotentKey(2L, 1L, "2025-01-02T01:00:00Z", "key-2");

Optional<UpcomingScheduleNotification> loaded =
repository.findByScheduleIdAndOwnerId(2L, 1L);

assertThat(loaded).isPresent();
assertThat(loaded.get().getScheduleStartTime()).isEqualTo("2025-01-02T01:00:00Z");
assertThat(loaded.get().getIdempotentKey()).isEqualTo("key-2");
}
}
Loading