Skip to content
Closed
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,247 @@
package in.koreatech.koin.domain.community.article.model;

import static jakarta.persistence.EnumType.STRING;
import static jakarta.persistence.FetchType.LAZY;
import static jakarta.persistence.GenerationType.IDENTITY;
import static lombok.AccessLevel.PROTECTED;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;

import org.hibernate.annotations.Where;

import in.koreatech.koin.common.model.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Entity
@Where(clause = "is_deleted=0")
@Table(name = "article_ai_summaries", uniqueConstraints = {
@UniqueConstraint(name = "uk_article_ai_summaries_article_id", columnNames = "article_id")
})
@NoArgsConstructor(access = PROTECTED)
public class ArticleAiSummary extends BaseEntity {

private static final int FAILURE_REASON_LIMIT = 500;

@Id
@GeneratedValue(strategy = IDENTITY)
private Integer id;

@OneToOne(fetch = LAZY)
@JoinColumn(name = "article_id", nullable = false)
private Article article;

@NotNull
@Enumerated(STRING)
@Column(name = "status", nullable = false)
private ArticleAiSummaryStatus status = ArticleAiSummaryStatus.WAIT;

@Column(name = "summary_content", columnDefinition = "MEDIUMTEXT")
private String summaryContent;

@Column(name = "summarized_at", columnDefinition = "TIMESTAMP")
private LocalDateTime summarizedAt;

@Column(name = "source_fingerprint", length = 64)
private String sourceFingerprint;

@Column(name = "source_updated_at", columnDefinition = "TIMESTAMP")
private LocalDateTime sourceUpdatedAt;

@Column(name = "model", length = 100)
private String model;

@Column(name = "prompt_version", length = 20)
private String promptVersion;

@NotNull
@Column(name = "retry_count", nullable = false)
private Integer retryCount = 0;

@Column(name = "next_attempt_at", columnDefinition = "TIMESTAMP")
private LocalDateTime nextAttemptAt;

@Column(name = "locked_until", columnDefinition = "TIMESTAMP")
private LocalDateTime lockedUntil;

@Column(name = "worker_id", length = 100)
private String workerId;

@Column(name = "failure_reason", length = 500)
private String failureReason;

@NotNull
@Column(name = "is_deleted", nullable = false)
private Boolean isDeleted = false;

@Builder
private ArticleAiSummary(
Article article,
ArticleAiSummaryStatus status,
String sourceFingerprint,
LocalDateTime sourceUpdatedAt,
String model,
String promptVersion
) {
this.article = article;
this.status = status;
this.sourceFingerprint = sourceFingerprint;
this.sourceUpdatedAt = sourceUpdatedAt;
this.model = model;
this.promptVersion = promptVersion;
}

public static ArticleAiSummary waiting(
Article article,
String sourceFingerprint,
LocalDateTime sourceUpdatedAt,
String model,
String promptVersion
) {
return ArticleAiSummary.builder()
.article(article)
.status(ArticleAiSummaryStatus.WAIT)
.sourceFingerprint(sourceFingerprint)
.sourceUpdatedAt(sourceUpdatedAt)
.model(model)
.promptVersion(promptVersion)
.build();
}

public boolean isSuccessFor(String fingerprint, String model, String promptVersion) {
return status == ArticleAiSummaryStatus.SUCCESS
&& Objects.equals(sourceFingerprint, fingerprint)
&& Objects.equals(this.model, model)
&& Objects.equals(this.promptVersion, promptVersion)
&& summaryContent != null
&& !summaryContent.isBlank();
}

public boolean isProcessing() {
return status == ArticleAiSummaryStatus.PROCESSING;
}

public boolean isProcessingBy(String workerId) {
return isProcessing() && Objects.equals(this.workerId, workerId);
}

public List<String> getSummaryLines() {
if (summaryContent == null || summaryContent.isBlank()) {
return List.of();
}
return summaryContent.lines()
.map(String::trim)
.filter(line -> !line.isBlank())
.toList();
}

public void prepareWait(
String sourceFingerprint,
LocalDateTime sourceUpdatedAt,
String model,
String promptVersion
) {
if (status == ArticleAiSummaryStatus.PROCESSING) {
return;
}
this.status = ArticleAiSummaryStatus.WAIT;
this.summaryContent = null;
this.summarizedAt = null;
this.sourceFingerprint = sourceFingerprint;
this.sourceUpdatedAt = sourceUpdatedAt;
this.model = model;
this.promptVersion = promptVersion;
this.retryCount = 0;
this.nextAttemptAt = null;
this.lockedUntil = null;
this.workerId = null;
this.failureReason = null;
}

public void markProcessing(String workerId, LocalDateTime lockedUntil) {
this.status = ArticleAiSummaryStatus.PROCESSING;
this.workerId = workerId;
this.lockedUntil = lockedUntil;
this.failureReason = null;
}

public void completeSuccess(
List<String> summaryLines,
String sourceFingerprint,
LocalDateTime sourceUpdatedAt,
String model,
String promptVersion,
LocalDateTime summarizedAt
) {
this.status = ArticleAiSummaryStatus.SUCCESS;
this.summaryContent = String.join("\n", summaryLines);
this.summarizedAt = summarizedAt;
this.sourceFingerprint = sourceFingerprint;
this.sourceUpdatedAt = sourceUpdatedAt;
this.model = model;
this.promptVersion = promptVersion;
this.nextAttemptAt = null;
this.lockedUntil = null;
this.workerId = null;
this.failureReason = null;
}

public void completeFailure(String reason, LocalDateTime nextAttemptAt) {
this.status = ArticleAiSummaryStatus.FAILED;
this.retryCount++;
this.nextAttemptAt = nextAttemptAt;
this.lockedUntil = null;
this.workerId = null;
this.failureReason = truncate(reason);
}

public void waitForRetry(String reason, LocalDateTime nextAttemptAt) {
this.status = ArticleAiSummaryStatus.WAIT;
this.retryCount++;
this.nextAttemptAt = nextAttemptAt;
this.lockedUntil = null;
this.workerId = null;
this.failureReason = truncate(reason);
}

public void completeFailureWithoutRetry(String reason, int maxRetryCount) {
this.status = ArticleAiSummaryStatus.FAILED;
this.retryCount = Math.max(this.retryCount + 1, maxRetryCount);
this.nextAttemptAt = null;
this.lockedUntil = null;
this.workerId = null;
this.failureReason = truncate(reason);
}

public void skip(String reason) {
this.status = ArticleAiSummaryStatus.SKIPPED;
this.nextAttemptAt = null;
this.lockedUntil = null;
this.workerId = null;
this.failureReason = truncate(reason);
}

private String truncate(String reason) {
if (reason == null) {
return null;
}
if (reason.length() <= FAILURE_REASON_LIMIT) {
return reason;
}
return reason.substring(0, FAILURE_REASON_LIMIT);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package in.koreatech.koin.domain.community.article.model;

public enum ArticleAiSummaryStatus {
WAIT,
PROCESSING,
SUCCESS,
FAILED,
SKIPPED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package in.koreatech.koin.domain.community.article.repository;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

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

import in.koreatech.koin.domain.community.article.model.ArticleAiSummary;

public interface ArticleAiSummaryRepository extends Repository<ArticleAiSummary, Integer> {

ArticleAiSummary save(ArticleAiSummary articleAiSummary);

Optional<ArticleAiSummary> findById(Integer id);

Optional<ArticleAiSummary> findByArticleId(Integer articleId);

@Modifying
@Query(value = """
INSERT INTO article_ai_summaries
(article_id, status, source_fingerprint, source_updated_at, model, prompt_version, retry_count,
is_deleted, created_at, updated_at)
VALUES
(:articleId, 'WAIT', :sourceFingerprint, :sourceUpdatedAt, :model, :promptVersion, 0,
0, NOW(), NOW())
ON DUPLICATE KEY UPDATE
status = IF(is_deleted = 1, 'WAIT', status),
source_fingerprint = IF(is_deleted = 1, :sourceFingerprint, source_fingerprint),
source_updated_at = IF(is_deleted = 1, :sourceUpdatedAt, source_updated_at),
model = IF(is_deleted = 1, :model, model),
prompt_version = IF(is_deleted = 1, :promptVersion, prompt_version),
retry_count = IF(is_deleted = 1, 0, retry_count),
next_attempt_at = IF(is_deleted = 1, NULL, next_attempt_at),
locked_until = IF(is_deleted = 1, NULL, locked_until),
worker_id = IF(is_deleted = 1, NULL, worker_id),
failure_reason = IF(is_deleted = 1, NULL, failure_reason),
updated_at = IF(is_deleted = 1, NOW(), updated_at),
is_deleted = 0
""", nativeQuery = true)
void insertWaitIfAbsent(
@Param("articleId") Integer articleId,
@Param("sourceFingerprint") String sourceFingerprint,
@Param("sourceUpdatedAt") LocalDateTime sourceUpdatedAt,
@Param("model") String model,
@Param("promptVersion") String promptVersion
);

@Query("""
SELECT DISTINCT s
FROM ArticleAiSummary s
JOIN FETCH s.article a
LEFT JOIN FETCH a.attachments
WHERE s.id = :id
""")
Optional<ArticleAiSummary> findByIdWithArticle(@Param("id") Integer id);

@Query(value = """
SELECT *
FROM article_ai_summaries
WHERE is_deleted = 0
AND (
(
status = 'WAIT'
AND (locked_until IS NULL OR locked_until < :now)
AND (next_attempt_at IS NULL OR next_attempt_at <= :now)
)
OR (
status = 'PROCESSING'
AND locked_until < :now
)
)
ORDER BY
CASE WHEN status = 'WAIT' THEN 0 ELSE 1 END,
updated_at ASC
LIMIT :limit
FOR UPDATE SKIP LOCKED
""", nativeQuery = true)
List<ArticleAiSummary> findWaitingSummariesForUpdate(
@Param("now") LocalDateTime now,
@Param("limit") int limit
);

@Query(value = """
SELECT *
FROM article_ai_summaries
WHERE is_deleted = 0
AND status = 'FAILED'
AND retry_count < :maxRetryCount
AND (next_attempt_at IS NULL OR next_attempt_at <= :now)
AND (locked_until IS NULL OR locked_until < :now)
ORDER BY
next_attempt_at ASC,
updated_at ASC
LIMIT :limit
FOR UPDATE SKIP LOCKED
""", nativeQuery = true)
List<ArticleAiSummary> findRetryableFailedSummariesForUpdate(
@Param("now") LocalDateTime now,
@Param("limit") int limit,
@Param("maxRetryCount") int maxRetryCount
);

}
Loading
Loading