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
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.example.solidconnection.chat.config;

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.time.Duration;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.validator.constraints.time.DurationMin;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;

@Component
@Validated
@ConfigurationProperties(prefix = "chat.outbox")
@Getter
@Setter
public class ChatMessageOutboxProperties {

@Positive
private int maxAttempts = 5;
@Positive
private int batchSize = 100;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
@NotNull
@DurationMin(nanos = 1)
private Duration initialRetryDelay = Duration.ofSeconds(1);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
@NotNull
@DurationMin(nanos = 1)
private Duration maxRetryDelay = Duration.ofMinutes(1);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
@NotNull
@DurationMin(nanos = 1)
private Duration processingTimeout = Duration.ofMinutes(1);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.example.solidconnection.chat.domain;

import com.example.solidconnection.common.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.ZonedDateTime;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(
name = "chat_message_outbox",
uniqueConstraints = @UniqueConstraint(
name = "uk_chat_message_outbox_message_recipient",
columnNames = {"chat_message_id", "recipient_chat_participant_id"}
),
indexes = {
@Index(
name = "idx_chat_message_outbox_pending",
columnList = "status, next_attempt_at"
),
@Index(
name = "idx_chat_message_outbox_processing",
columnList = "status, processing_started_at"
)
}
)
public class ChatMessageOutbox extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "chat_message_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private ChatMessage chatMessage;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "recipient_chat_participant_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private ChatParticipant recipient;

@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private ChatMessageOutboxStatus status;

@Enumerated(EnumType.STRING)
@Column(name = "completion_type", nullable = true, length = 30)
private ChatMessageOutboxCompletionType completionType;

@ColumnDefault("0")
@Column(name = "attempt_count", nullable = false)
private int attemptCount = 0;

@Column(name = "processing_token", nullable = true, length = 36)
private String processingToken;

@Column(name = "processing_started_at", nullable = true)
private ZonedDateTime processingStartedAt;

@Column(name = "next_attempt_at", nullable = true)
private ZonedDateTime nextAttemptAt;

@Column(name = "last_error", nullable = true, length = 1000)
private String lastError;

@Column(name = "completed_at", nullable = true)
private ZonedDateTime completedAt;

public ChatMessageOutbox(
ChatMessage chatMessage,
ChatParticipant recipient,
ZonedDateTime nextAttemptAt
) {
this.chatMessage = chatMessage;
this.recipient = recipient;
this.status = ChatMessageOutboxStatus.PENDING;
this.nextAttemptAt = nextAttemptAt;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.example.solidconnection.chat.domain;

public enum ChatMessageOutboxCompletionType {

BROKER_DISPATCHED,
HISTORY_SERVED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example.solidconnection.chat.domain;

public enum ChatMessageOutboxStatus {

PENDING,
PROCESSING,
COMPLETED,
FAILED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.example.solidconnection.chat.dto;

import com.example.solidconnection.common.dto.SliceResponse;
import java.util.List;

public record ChatMessageHistoryResult(
SliceResponse<ChatMessageResponse> response,
long participantId,
List<Long> messageIds
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.solidconnection.chat.dto;

public record ClaimedChatMessageOutbox(
long outboxId,
String processingToken,
int attemptCount,
long roomId,
ChatMessageSendResponse response
) {
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
package com.example.solidconnection.chat.event;

import com.example.solidconnection.chat.dto.ChatMessageSendResponse;

public record ChatMessageBroadcastEvent(
long roomId,
ChatMessageSendResponse response
long outboxId
) {

}
Original file line number Diff line number Diff line change
@@ -1,29 +1,21 @@
package com.example.solidconnection.chat.event;

import com.example.solidconnection.chat.service.ChatMessageOutboxDispatcher;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
@RequiredArgsConstructor
@Slf4j
public class ChatMessageBroadcastEventListener {

private final SimpMessageSendingOperations simpMessageSendingOperations;
private final ChatMessageOutboxDispatcher dispatcher;

@Async("chatMessageOutboxExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void broadcast(ChatMessageBroadcastEvent event) {
try {
simpMessageSendingOperations.convertAndSend(
"/topic/chat/" + event.roomId(),
event.response()
);
} catch (RuntimeException e) {
log.error("채팅 메시지를 방송하지 못했습니다. roomId={}, messageId={}",
event.roomId(), event.response().messageId(), e);
}
dispatcher.dispatch(event.outboxId());
Comment thread
Hexeong marked this conversation as resolved.
Resolved
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package com.example.solidconnection.chat.repository;

import com.example.solidconnection.chat.domain.ChatMessageOutbox;
import com.example.solidconnection.chat.domain.ChatMessageOutboxCompletionType;
import com.example.solidconnection.chat.domain.ChatMessageOutboxStatus;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
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;

public interface ChatMessageOutboxRepository extends JpaRepository<ChatMessageOutbox, Long> {

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
UPDATE ChatMessageOutbox o
SET o.status = :processingStatus,
o.processingToken = :processingToken,
o.processingStartedAt = :now,
o.attemptCount = o.attemptCount + 1,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
o.updatedAt = :now
WHERE o.id = :outboxId
AND o.status = :pendingStatus
AND o.nextAttemptAt <= :now
AND o.attemptCount < :maxAttempts
""")
int claim(
@Param("outboxId") long outboxId,
@Param("processingToken") String processingToken,
@Param("now") ZonedDateTime now,
@Param("maxAttempts") int maxAttempts,
@Param("pendingStatus") ChatMessageOutboxStatus pendingStatus,
@Param("processingStatus") ChatMessageOutboxStatus processingStatus
);

@EntityGraph(attributePaths = {
"chatMessage",
"chatMessage.chatRoom",
"chatMessage.chatAttachments"
})
@Query("SELECT o FROM ChatMessageOutbox o WHERE o.id = :outboxId")
Optional<ChatMessageOutbox> findWithMessageById(@Param("outboxId") long outboxId);

@Query("""
SELECT o.id FROM ChatMessageOutbox o
WHERE o.status = :status
AND o.nextAttemptAt <= :now
AND o.attemptCount < :maxAttempts
ORDER BY o.nextAttemptAt, o.id
""")
List<Long> findDispatchableIds(
@Param("status") ChatMessageOutboxStatus status,
@Param("now") ZonedDateTime now,
@Param("maxAttempts") int maxAttempts,
Pageable pageable
);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
UPDATE ChatMessageOutbox o
SET o.status = :pendingStatus,
o.processingToken = null,
o.processingStartedAt = null,
o.nextAttemptAt = :now,
o.updatedAt = :now
WHERE o.status = :processingStatus
AND o.processingStartedAt < :cutoff
AND o.attemptCount < :maxAttempts
""")
int recoverRetryableStaleProcessing(
@Param("processingStatus") ChatMessageOutboxStatus processingStatus,
@Param("pendingStatus") ChatMessageOutboxStatus pendingStatus,
@Param("cutoff") ZonedDateTime cutoff,
@Param("now") ZonedDateTime now,
@Param("maxAttempts") int maxAttempts
);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
UPDATE ChatMessageOutbox o
SET o.status = :failedStatus,
o.processingToken = null,
o.processingStartedAt = null,
o.nextAttemptAt = null,
o.updatedAt = :now
WHERE o.status = :processingStatus
AND o.processingStartedAt < :cutoff
AND o.attemptCount >= :maxAttempts
""")
int failExhaustedStaleProcessing(
@Param("processingStatus") ChatMessageOutboxStatus processingStatus,
@Param("failedStatus") ChatMessageOutboxStatus failedStatus,
@Param("cutoff") ZonedDateTime cutoff,
@Param("now") ZonedDateTime now,
@Param("maxAttempts") int maxAttempts
);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
UPDATE ChatMessageOutbox o
SET o.status = :completedStatus,
o.completionType = :completionType,
o.processingToken = null,
o.processingStartedAt = null,
o.nextAttemptAt = null,
o.completedAt = :completedAt,
o.lastError = null,
o.updatedAt = :completedAt
WHERE o.id = :outboxId
AND o.status = :processingStatus
AND o.processingToken = :processingToken
""")
int markCompleted(
@Param("outboxId") long outboxId,
@Param("processingToken") String processingToken,
@Param("processingStatus") ChatMessageOutboxStatus processingStatus,
@Param("completedStatus") ChatMessageOutboxStatus completedStatus,
@Param("completionType") ChatMessageOutboxCompletionType completionType,
@Param("completedAt") ZonedDateTime completedAt
);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
UPDATE ChatMessageOutbox o
SET o.status = :nextStatus,
o.processingToken = null,
o.processingStartedAt = null,
o.nextAttemptAt = :nextAttemptAt,
o.lastError = :lastError,
o.updatedAt = :failedAt
WHERE o.id = :outboxId
AND o.status = :processingStatus
AND o.processingToken = :processingToken
""")
int recordFailure(
@Param("outboxId") long outboxId,
@Param("processingToken") String processingToken,
@Param("processingStatus") ChatMessageOutboxStatus processingStatus,
@Param("nextStatus") ChatMessageOutboxStatus nextStatus,
@Param("nextAttemptAt") ZonedDateTime nextAttemptAt,
@Param("lastError") String lastError,
@Param("failedAt") ZonedDateTime failedAt
);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
UPDATE ChatMessageOutbox o
SET o.status = :completedStatus,
o.completionType = :completionType,
o.processingToken = null,
o.processingStartedAt = null,
o.nextAttemptAt = null,
o.completedAt = :completedAt,
o.lastError = null,
o.updatedAt = :completedAt
WHERE o.recipient.id = :recipientParticipantId
AND o.chatMessage.id IN :messageIds
AND o.status IN :completableStatuses
""")
int markHistoryServed(
@Param("recipientParticipantId") long recipientParticipantId,
@Param("messageIds") List<Long> messageIds,
@Param("completableStatuses") List<ChatMessageOutboxStatus> completableStatuses,
@Param("completedStatus") ChatMessageOutboxStatus completedStatus,
@Param("completionType") ChatMessageOutboxCompletionType completionType,
@Param("completedAt") ZonedDateTime completedAt
);
}
Loading
Loading