From a9da029409bd51053f854a40f92dafbda64f2f75 Mon Sep 17 00:00:00 2001 From: GiminKim Date: Sun, 13 Sep 2026 18:36:19 +0900 Subject: [PATCH 1/6] feat: add async pipeline transition metrics (#332) --- .../DocumentIndexingCompletionService.java | 6 +- .../DocumentIndexingFailureService.java | 9 +- .../EmbeddingJobLeaseRecoveryService.java | 8 ++ .../docgrid/domain/rag/service/RagFacade.java | 24 +++- .../command/SyncEventDispatchService.java | 7 ++ .../command/SyncEventFailureService.java | 10 ++ .../SyncEventLeaseRecoveryService.java | 12 +- .../observability/AsyncPipelineMetrics.java | 60 +++++++++ .../EmbeddingJobAttemptMetricEvent.java | 119 ++++++++++++++++++ .../RagJobCompletionMetricEvent.java | 37 ++++++ .../SyncEventAttemptMetricEvent.java | 36 ++++++ .../EmbeddingJobLeaseRecoveryServiceTest.java | 23 +++- .../domain/rag/service/RagFacadeTest.java | 17 +++ .../command/SyncEventDispatchServiceTest.java | 10 +- ...lineMetricsAfterCommitIntegrationTest.java | 101 +++++++++++++++ .../AsyncPipelineMetricsTest.java | 87 +++++++++++++ ...-#332-async-pipeline-transition-metrics.md | 97 ++++++++++++++ ...-#332-async-pipeline-transition-metrics.md | 75 +++++++++++ monitoring/prometheus/README.md | 25 +++- .../rules/docgrid-pipeline-alerts.yml | 76 +++++++++++ .../tests/docgrid-pipeline-alerts.test.yml | 98 +++++++++++++++ 21 files changed, 926 insertions(+), 11 deletions(-) create mode 100644 backend/src/main/java/com/opensource/docgrid/global/observability/AsyncPipelineMetrics.java create mode 100644 backend/src/main/java/com/opensource/docgrid/global/observability/EmbeddingJobAttemptMetricEvent.java create mode 100644 backend/src/main/java/com/opensource/docgrid/global/observability/RagJobCompletionMetricEvent.java create mode 100644 backend/src/main/java/com/opensource/docgrid/global/observability/SyncEventAttemptMetricEvent.java create mode 100644 backend/src/test/java/com/opensource/docgrid/global/observability/AsyncPipelineMetricsAfterCommitIntegrationTest.java create mode 100644 backend/src/test/java/com/opensource/docgrid/global/observability/AsyncPipelineMetricsTest.java create mode 100644 docs/design/gimin-#332-async-pipeline-transition-metrics.md create mode 100644 docs/test-results/gimin-#332-async-pipeline-transition-metrics.md create mode 100644 monitoring/prometheus/rules/docgrid-pipeline-alerts.yml create mode 100644 monitoring/prometheus/tests/docgrid-pipeline-alerts.test.yml diff --git a/backend/src/main/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingCompletionService.java b/backend/src/main/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingCompletionService.java index f210459c..8ccbf2cc 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingCompletionService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingCompletionService.java @@ -37,6 +37,7 @@ import com.opensource.docgrid.domain.worker.repository.IndexingEventRepository; import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; +import com.opensource.docgrid.global.observability.EmbeddingJobAttemptMetricEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -137,9 +138,10 @@ public DocumentIndexingCompletionResponse complete( durationMs ); - // 6. 대시보드가 최신 집계를 다시 계산하도록 상태 전이를 알린다. AFTER_COMMIT 구독자만 - // 반응하므로 이 Transaction이 실제로 커밋된 뒤에만 push로 이어진다. + // 6. 대시보드 갱신과 성공 Counter를 같은 Commit에 결박한다. 두 AFTER_COMMIT 구독자는 + // 이 Transaction이 실제로 커밋된 뒤에만 반응한다. applicationEventPublisher.publishEvent(new EmbeddingJobStatusChangedEvent(embeddingJob.getId())); + applicationEventPublisher.publishEvent(EmbeddingJobAttemptMetricEvent.success()); log.info( "문서 인덱싱 완료: jobId={}, attemptId={}, documentId={}, versionId={}, " diff --git a/backend/src/main/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingFailureService.java b/backend/src/main/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingFailureService.java index 9c564fac..a3223571 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingFailureService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingFailureService.java @@ -29,6 +29,7 @@ import com.opensource.docgrid.domain.worker.repository.IndexingEventRepository; import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; +import com.opensource.docgrid.global.observability.EmbeddingJobAttemptMetricEvent; import lombok.extern.slf4j.Slf4j; @@ -167,11 +168,15 @@ public DocumentIndexingFailureResponse fail( minimumRetryDelay ); - // 4. 대시보드가 최신 집계를 다시 계산하도록 상태 전이를 알린다. transition()이 재시도 예약 + // 4. 대시보드 갱신과 실패 Counter를 같은 Commit에 결박한다. transition()이 재시도 예약 // (PENDING)과 최종 실패(FAILED) 중 어느 쪽으로 끝났든 embeddingJob은 같은 영속 인스턴스라 // 최종 상태를 그대로 반영한다. AFTER_COMMIT 구독자만 반응하므로 이 Transaction이 실제로 - // 커밋된 뒤에만 push로 이어진다. + // 커밋된 뒤에만 push와 Counter 증가로 이어진다. applicationEventPublisher.publishEvent(new EmbeddingJobStatusChangedEvent(embeddingJob.getId())); + applicationEventPublisher.publishEvent(EmbeddingJobAttemptMetricEvent.failure( + embeddingJob.getStatus(), + request.failureType() + )); log.info( "문서 인덱싱 실패 기록: jobId={}, attemptId={}, failureType={}, retryCount={}, terminal={}", diff --git a/backend/src/main/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobLeaseRecoveryService.java b/backend/src/main/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobLeaseRecoveryService.java index 610c9f98..e1511f36 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobLeaseRecoveryService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobLeaseRecoveryService.java @@ -6,6 +6,7 @@ import java.util.Optional; import java.util.UUID; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -21,6 +22,7 @@ import com.opensource.docgrid.domain.worker.repository.IndexingEventRepository; import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; +import com.opensource.docgrid.global.observability.EmbeddingJobAttemptMetricEvent; import lombok.RequiredArgsConstructor; @@ -43,6 +45,7 @@ public class EmbeddingJobLeaseRecoveryService { private final EmbeddingJobAttemptRepository embeddingJobAttemptRepository; private final IndexingEventRepository indexingEventRepository; private final IndexingFailureTransitionService failureTransitionService; + private final ApplicationEventPublisher applicationEventPublisher; /** * 후보 Snapshot 이후에도 만료 상태인 Job만 회수해 Retry 또는 최종 실패로 전환한다. @@ -84,6 +87,11 @@ public RecoveryResult recover(Long jobId, LocalDateTime recoveredAt) { recoveredAt, Duration.ZERO ); + + // 5. 실제 회수 전이가 커밋된 뒤에만 재시도 또는 최종 실패 Counter가 증가하게 한다. + applicationEventPublisher.publishEvent( + EmbeddingJobAttemptMetricEvent.leaseExpired(embeddingJob.getStatus()) + ); return RecoveryResult.recovered(embeddingJob); } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java index d7e5aa91..41309ad9 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java @@ -2,6 +2,7 @@ import java.util.List; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,6 +22,8 @@ import com.opensource.docgrid.domain.search.service.query.SearchConversationQueryService; import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; +import com.opensource.docgrid.global.observability.RagJobCompletionMetricEvent; +import com.opensource.docgrid.global.observability.RagJobCompletionMetricEvent.Outcome; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; @@ -88,6 +91,7 @@ public class RagFacade { private final SearchResultRepository searchResultRepository; private final SearchConversationQueryService searchConversationQueryService; private final EntityManager entityManager; + private final ApplicationEventPublisher applicationEventPublisher; /** * 검색 직후 SearchController가 동기 호출하는 접수 단계. Ollama는 아직 호출하지 않는다. @@ -107,6 +111,8 @@ public RagEnqueueOutcome enqueue( if (candidates.isEmpty()) { RagResponse ragResponse = ragResponseCommandService.createNoContext(queryRef); + // LLM 호출을 생략한 정상 완료도 저장 Transaction이 커밋된 뒤 별도 결과로 집계한다. + applicationEventPublisher.publishEvent(new RagJobCompletionMetricEvent(Outcome.NO_CONTEXT)); log.info("[RAG] no context queryId={} responseId={}", queryId, ragResponse.getId()); return RagEnqueueOutcome.done(RagAnswer.noContext(ragResponse.getAnswerText())); } @@ -169,6 +175,11 @@ public boolean processJob(Long jobId) { String fallbackAnswer = candidates.isEmpty() ? e.getErrorCode().getMessage() : buildExtractiveFallbackAnswer(candidates); boolean completed = ragResponseCommandService.completeFailed(job, fallbackAnswer, e.getMessage()); + if (completed) { + applicationEventPublisher.publishEvent( + new RagJobCompletionMetricEvent(Outcome.PROVIDER_FALLBACK) + ); + } log.warn("[RAG] fallback queryId={} errorCode={}", queryId, e.getErrorCode().getCode()); return completed; } @@ -216,6 +227,8 @@ public boolean processJob(Long jobId) { // 3. 완료 처리가 성공한 답변에만 선택한 후보 순서대로 출처를 저장한다. responseCitationCommandService.saveAll(job, citationCandidates, citationSearchResults); } + // 답변과 citation 저장이 모두 끝난 동일 Transaction의 커밋 이후 성공 Counter를 기록한다. + applicationEventPublisher.publishEvent(new RagJobCompletionMetricEvent(Outcome.SUCCESS)); log.info("[RAG] done queryId={} responseId={} latencyMs={}", queryId, job.getId(), result.latencyMs()); return true; } @@ -232,9 +245,15 @@ public boolean processJob(Long jobId) { * (RagJobWorker)는 이 경우 WebSocket 알림을 보내지 않는다. */ public boolean markUnexpectedFailure(Long jobId, String errorMessage) { - return ragResponseRepository.findById(jobId) + boolean completed = ragResponseRepository.findById(jobId) .map(job -> ragResponseCommandService.completeFailed(job, UNEXPECTED_FAILURE_ANSWER_TEXT, errorMessage)) .orElse(false); + if (completed) { + applicationEventPublisher.publishEvent( + new RagJobCompletionMetricEvent(Outcome.UNEXPECTED_FAILURE) + ); + } + return completed; } /** @@ -252,6 +271,9 @@ public boolean failIfStillProcessing(Long jobId, Long queryId) { ? UNEXPECTED_FAILURE_ANSWER_TEXT : buildExtractiveFallbackAnswer(candidates); int updated = ragResponseRepository.forceFailIfProcessing(jobId, fallbackAnswer, TIMEOUT_ERROR_MESSAGE); + if (updated > 0) { + applicationEventPublisher.publishEvent(new RagJobCompletionMetricEvent(Outcome.TIMEOUT_SWEPT)); + } return updated > 0; } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncEventDispatchService.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncEventDispatchService.java index 54e30848..3e0bbd81 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncEventDispatchService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncEventDispatchService.java @@ -4,6 +4,7 @@ import java.time.LocalDateTime; import java.util.Objects; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -15,6 +16,8 @@ import com.opensource.docgrid.domain.sync.service.SyncEventHandlerRegistry; import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; +import com.opensource.docgrid.global.observability.SyncEventAttemptMetricEvent; +import com.opensource.docgrid.global.observability.SyncEventAttemptMetricEvent.Outcome; import lombok.RequiredArgsConstructor; @@ -33,6 +36,7 @@ public class SyncEventDispatchService { private final SyncEventHandlerRegistry syncEventHandlerRegistry; private final SyncEventDeliveryAttemptService syncEventDeliveryAttemptService; private final Clock clock; + private final ApplicationEventPublisher applicationEventPublisher; /** * Claim된 Outbox Event의 Handler 부작용과 완료 전이를 독립 트랜잭션으로 실행한다. @@ -64,6 +68,9 @@ public void dispatch(ClaimedSyncEvent claimedEvent) { completedAt ); completionEvent.complete(claimedEvent.claimToken(), completedAt); + + // 5. Handler 부작용과 완료 상태가 함께 커밋된 뒤에만 처리 성공 Counter를 기록한다. + applicationEventPublisher.publishEvent(new SyncEventAttemptMetricEvent(Outcome.PROCESSED)); } /** diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncEventFailureService.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncEventFailureService.java index 33205792..c2b8efdf 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncEventFailureService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncEventFailureService.java @@ -5,6 +5,7 @@ import java.util.Objects; import java.util.UUID; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -15,6 +16,8 @@ import com.opensource.docgrid.domain.sync.service.SyncEventRetrySchedule; import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; +import com.opensource.docgrid.global.observability.SyncEventAttemptMetricEvent; +import com.opensource.docgrid.global.observability.SyncEventAttemptMetricEvent.Outcome; import lombok.RequiredArgsConstructor; @@ -31,6 +34,7 @@ public class SyncEventFailureService { private final SyncEventRetrySchedule syncEventRetrySchedule; private final SyncEventDeliveryAttemptService syncEventDeliveryAttemptService; private final Clock clock; + private final ApplicationEventPublisher applicationEventPublisher; /** * Handler 실패를 현재 Claim의 Delivery Attempt와 Queue 상태에 함께 기록한다. @@ -54,6 +58,9 @@ public void recordFailure(UUID eventId, UUID claimToken, String errorCode, Strin // 4. 이번 실패가 허용 횟수를 채우면 다시 Claim되지 않는 최종 상태로 종결한다. if (event.getRetryCount() + 1 >= event.getMaxRetryCount()) { event.markFailed(claimToken, errorCode, errorMessage, failedAt); + applicationEventPublisher.publishEvent( + new SyncEventAttemptMetricEvent(Outcome.TERMINAL_FAILURE) + ); return; } @@ -65,6 +72,9 @@ public void recordFailure(UUID eventId, UUID claimToken, String errorCode, Strin failedAt, syncEventRetrySchedule.nextAvailableAt(event, failedAt) ); + applicationEventPublisher.publishEvent( + new SyncEventAttemptMetricEvent(Outcome.RETRY_SCHEDULED) + ); } /** diff --git a/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncEventLeaseRecoveryService.java b/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncEventLeaseRecoveryService.java index 6d4f72c3..24cc404c 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncEventLeaseRecoveryService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/sync/service/command/SyncEventLeaseRecoveryService.java @@ -4,6 +4,7 @@ import java.util.Optional; import java.util.UUID; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -12,6 +13,8 @@ import com.opensource.docgrid.domain.sync.enums.SyncEventStatus; import com.opensource.docgrid.domain.sync.repository.SyncOutboxEventRepository; import com.opensource.docgrid.domain.sync.service.SyncEventRetrySchedule; +import com.opensource.docgrid.global.observability.SyncEventAttemptMetricEvent; +import com.opensource.docgrid.global.observability.SyncEventAttemptMetricEvent.Outcome; import lombok.RequiredArgsConstructor; @@ -28,6 +31,7 @@ public class SyncEventLeaseRecoveryService { private final SyncOutboxEventRepository syncOutboxEventRepository; private final SyncEventRetrySchedule syncEventRetrySchedule; private final SyncEventDeliveryAttemptService syncEventDeliveryAttemptService; + private final ApplicationEventPublisher applicationEventPublisher; /** * 만료 후보 Event를 다시 확인해 실제로 만료된 현재 Claim만 회수한다. @@ -65,7 +69,13 @@ public RecoveryResult recover(UUID eventId, LocalDateTime recoveredAt) { syncEventRetrySchedule.nextAvailableAt(event, recoveredAt) ); - // 5. Scheduler가 회수 결과를 집계할 수 있도록 변경 후 상태를 반환한다. + // 5. 최종 실패는 즉시 조치 경보 대상이며, 재예약된 Lease 만료는 회수 활동으로 구분한다. + Outcome outcome = event.getStatus() == SyncEventStatus.FAILED + ? Outcome.TERMINAL_FAILURE + : Outcome.LEASE_RECOVERED; + applicationEventPublisher.publishEvent(new SyncEventAttemptMetricEvent(outcome)); + + // 6. Scheduler가 회수 결과를 집계할 수 있도록 변경 후 상태를 반환한다. return new RecoveryResult(eventId, true, event.getStatus()); } diff --git a/backend/src/main/java/com/opensource/docgrid/global/observability/AsyncPipelineMetrics.java b/backend/src/main/java/com/opensource/docgrid/global/observability/AsyncPipelineMetrics.java new file mode 100644 index 00000000..ac5f86f0 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/global/observability/AsyncPipelineMetrics.java @@ -0,0 +1,60 @@ +package com.opensource.docgrid.global.observability; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import io.micrometer.core.instrument.MeterRegistry; +import lombok.RequiredArgsConstructor; + +/** + * 비동기 파이프라인의 커밋된 상태 전이를 Micrometer Counter로 변환한다. + * + *

도메인 서비스는 DB 상태와 같은 트랜잭션에서 제한된 이벤트만 발행한다. 이 구독자는 + * {@link TransactionPhase#AFTER_COMMIT}에만 실행되므로 롤백된 상태 변화는 Counter에 남지 않는다. + */ +@Component +@RequiredArgsConstructor +public class AsyncPipelineMetrics { + + private static final String EMBEDDING_ATTEMPTS = "docgrid.embedding.job.attempts"; + private static final String RAG_COMPLETIONS = "docgrid.rag.job.completions"; + private static final String SYNC_ATTEMPTS = "docgrid.sync.event.attempts"; + + private final MeterRegistry meterRegistry; + + /** + * 확정된 Embedding 실행 결과를 실패 분류와 재시도 가능 여부별로 기록한다. + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void recordEmbeddingAttempt(EmbeddingJobAttemptMetricEvent event) { + meterRegistry.counter( + EMBEDDING_ATTEMPTS, + "outcome", event.outcome().label(), + "failure_type", event.failureType().name(), + "retryable", Boolean.toString(event.failureType().isRetryable()) + ).increment(); + } + + /** + * 확정된 RAG 답변 결과를 정상·fallback·timeout 경로별로 기록한다. + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void recordRagCompletion(RagJobCompletionMetricEvent event) { + meterRegistry.counter( + RAG_COMPLETIONS, + "outcome", event.outcome().label() + ).increment(); + } + + /** + * 확정된 Sync Outbox 처리 결과를 완료·재시도·종결·Lease 회수별로 기록한다. + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void recordSyncAttempt(SyncEventAttemptMetricEvent event) { + meterRegistry.counter( + SYNC_ATTEMPTS, + "outcome", event.outcome().label() + ).increment(); + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/global/observability/EmbeddingJobAttemptMetricEvent.java b/backend/src/main/java/com/opensource/docgrid/global/observability/EmbeddingJobAttemptMetricEvent.java new file mode 100644 index 00000000..e14eb212 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/global/observability/EmbeddingJobAttemptMetricEvent.java @@ -0,0 +1,119 @@ +package com.opensource.docgrid.global.observability; + +import java.util.Objects; + +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; +import com.opensource.docgrid.domain.embedding.enums.IndexingFailureType; + +/** + * 커밋이 확정된 Embedding Job 실행 결과를 제한된 Micrometer label 값으로 전달한다. + * + *

Job ID나 오류 메시지를 싣지 않아 시계열 cardinality가 입력 데이터에 따라 증가하지 않는다. + */ +public record EmbeddingJobAttemptMetricEvent( + Outcome outcome, + FailureType failureType +) { + + /** + * 성공한 실행의 단일 label 조합을 생성한다. + */ + public static EmbeddingJobAttemptMetricEvent success() { + return new EmbeddingJobAttemptMetricEvent(Outcome.SUCCESS, FailureType.NONE); + } + + /** + * Worker가 보고한 고정 실패 분류와 전이 후 상태로 재시도 또는 최종 실패를 구분한다. + */ + public static EmbeddingJobAttemptMetricEvent failure( + EmbeddingJobStatus status, + IndexingFailureType failureType + ) { + return new EmbeddingJobAttemptMetricEvent( + Outcome.fromFailureStatus(status), + FailureType.from(failureType) + ); + } + + /** + * Worker Lease 만료는 외부 요청 enum과 분리된 고정 운영 실패 분류로 기록한다. + */ + public static EmbeddingJobAttemptMetricEvent leaseExpired(EmbeddingJobStatus status) { + return new EmbeddingJobAttemptMetricEvent( + Outcome.fromFailureStatus(status), + FailureType.WORKER_LEASE_EXPIRED + ); + } + + /** + * 성공과 실패 label 조합이 서로 모순되지 않도록 생성 시점에 검증한다. + */ + public EmbeddingJobAttemptMetricEvent { + Objects.requireNonNull(outcome, "outcome은 필수입니다."); + Objects.requireNonNull(failureType, "failureType은 필수입니다."); + if ((outcome == Outcome.SUCCESS) != (failureType == FailureType.NONE)) { + throw new IllegalArgumentException("성공 결과만 NONE 실패 유형을 사용할 수 있습니다."); + } + } + + /** + * Embedding 실행이 확정된 뒤 노출할 저 cardinality 결과 label이다. + */ + public enum Outcome { + SUCCESS("success"), + RETRY_SCHEDULED("retry_scheduled"), + TERMINAL_FAILURE("terminal_failure"); + + private final String label; + + Outcome(String label) { + this.label = label; + } + + public String label() { + return label; + } + + private static Outcome fromFailureStatus(EmbeddingJobStatus status) { + return switch (Objects.requireNonNull(status, "status는 필수입니다.")) { + case PENDING -> RETRY_SCHEDULED; + case FAILED -> TERMINAL_FAILURE; + default -> throw new IllegalArgumentException("실패 전이 후 상태는 PENDING 또는 FAILED여야 합니다."); + }; + } + } + + /** + * 외부 요청 enum과 내부 Lease 회수 원인을 합친 고정 실패 label 목록이다. + */ + public enum FailureType { + NONE(false), + STORAGE_UNAVAILABLE(true), + STORAGE_CONFIGURATION_INVALID(false), + STORAGE_OBJECT_MISSING(false), + DOCUMENT_CONTENT_INVALID(false), + EMBEDDING_PROVIDER_UNAVAILABLE(true), + EMBEDDING_PROVIDER_OVERLOADED(true), + EMBEDDING_PROVIDER_TIMEOUT(true), + EMBEDDING_PROVIDER_CIRCUIT_OPEN(true), + EMBEDDING_REQUEST_INVALID(false), + EMBEDDING_RESULT_INVALID(false), + INDEXING_STATE_INCONSISTENT(false), + WORKER_INTERNAL_ERROR(true), + WORKER_LEASE_EXPIRED(true); + + private final boolean retryable; + + FailureType(boolean retryable) { + this.retryable = retryable; + } + + public boolean isRetryable() { + return retryable; + } + + private static FailureType from(IndexingFailureType failureType) { + return valueOf(Objects.requireNonNull(failureType, "failureType은 필수입니다.").name()); + } + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/global/observability/RagJobCompletionMetricEvent.java b/backend/src/main/java/com/opensource/docgrid/global/observability/RagJobCompletionMetricEvent.java new file mode 100644 index 00000000..147a07d3 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/global/observability/RagJobCompletionMetricEvent.java @@ -0,0 +1,37 @@ +package com.opensource.docgrid.global.observability; + +import java.util.Objects; + +/** + * 커밋이 확정된 RAG Job의 최종 처리 경로를 고정된 결과 label로 전달한다. + */ +public record RagJobCompletionMetricEvent(Outcome outcome) { + + /** + * 자유 형식 결과가 metric label로 유입되지 않도록 enum 값만 허용한다. + */ + public RagJobCompletionMetricEvent { + Objects.requireNonNull(outcome, "outcome은 필수입니다."); + } + + /** + * 정상 답변과 각 fallback·회수 원인을 구분하는 RAG 완료 결과다. + */ + public enum Outcome { + SUCCESS("success"), + NO_CONTEXT("no_context"), + PROVIDER_FALLBACK("provider_fallback"), + TIMEOUT_SWEPT("timeout_swept"), + UNEXPECTED_FAILURE("unexpected_failure"); + + private final String label; + + Outcome(String label) { + this.label = label; + } + + public String label() { + return label; + } + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/global/observability/SyncEventAttemptMetricEvent.java b/backend/src/main/java/com/opensource/docgrid/global/observability/SyncEventAttemptMetricEvent.java new file mode 100644 index 00000000..e78456f7 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/global/observability/SyncEventAttemptMetricEvent.java @@ -0,0 +1,36 @@ +package com.opensource.docgrid.global.observability; + +import java.util.Objects; + +/** + * 커밋이 확정된 Sync Outbox 처리 또는 Lease 회수 결과를 고정된 label로 전달한다. + */ +public record SyncEventAttemptMetricEvent(Outcome outcome) { + + /** + * 자유 형식 오류 코드가 metric label로 유입되지 않도록 enum 값만 허용한다. + */ + public SyncEventAttemptMetricEvent { + Objects.requireNonNull(outcome, "outcome은 필수입니다."); + } + + /** + * Outbox가 실제로 확정한 처리 결과다. + */ + public enum Outcome { + PROCESSED("processed"), + RETRY_SCHEDULED("retry_scheduled"), + TERMINAL_FAILURE("terminal_failure"), + LEASE_RECOVERED("lease_recovered"); + + private final String label; + + Outcome(String label) { + this.label = label; + } + + public String label() { + return label; + } + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobLeaseRecoveryServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobLeaseRecoveryServiceTest.java index def62893..5c0ddec2 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobLeaseRecoveryServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobLeaseRecoveryServiceTest.java @@ -6,6 +6,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.lenient; import java.time.Duration; import java.time.LocalDateTime; @@ -18,6 +19,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.util.ReflectionTestUtils; import com.opensource.docgrid.domain.document.entity.DocumentVersion; @@ -36,6 +38,7 @@ import com.opensource.docgrid.domain.worker.repository.IndexingEventRepository; import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; +import com.opensource.docgrid.global.observability.EmbeddingJobAttemptMetricEvent; /** * EmbeddingJobLeaseRecoveryService의 후보 재검증, 선택적 Attempt와 공통 실패 전이 위임을 검증한다. @@ -59,6 +62,7 @@ class EmbeddingJobLeaseRecoveryServiceTest { @Mock private EmbeddingJobAttemptRepository embeddingJobAttemptRepository; @Mock private IndexingEventRepository indexingEventRepository; @Mock private IndexingFailureTransitionService failureTransitionService; + @Mock private ApplicationEventPublisher applicationEventPublisher; private EmbeddingJobLeaseRecoveryService recoveryService; @@ -68,7 +72,19 @@ void setUp() { embeddingJobRepository, embeddingJobAttemptRepository, indexingEventRepository, - failureTransitionService + failureTransitionService, + applicationEventPublisher + ); + // 공통 실패 전이는 mock이므로, 운영 구현처럼 회수 후 상태가 PENDING이 되도록 반영한다. + lenient().doAnswer(invocation -> { + ReflectionTestUtils.setField( + invocation.getArgument(0), + "status", + EmbeddingJobStatus.PENDING + ); + return null; + }).when(failureTransitionService).transition( + any(), any(), any(), any(), any(Boolean.class), any(), any() ); } @@ -104,6 +120,8 @@ void recover_transitionsStartedAttemptAndSavesLeaseExpiredEvent() { ); assertThat(result.recovered()).isTrue(); assertThat(result.jobId()).isEqualTo(JOB_ID); + then(applicationEventPublisher).should() + .publishEvent(EmbeddingJobAttemptMetricEvent.leaseExpired(EmbeddingJobStatus.PENDING)); } @Test @@ -132,6 +150,8 @@ void recover_transitionsJobWithoutCreatingAttempt_when_attemptDoesNotExist() { Duration.ZERO ); assertThat(result.recovered()).isTrue(); + then(applicationEventPublisher).should() + .publishEvent(EmbeddingJobAttemptMetricEvent.leaseExpired(EmbeddingJobStatus.PENDING)); } @Test @@ -147,6 +167,7 @@ void recover_skips_when_candidateCannotBeLocked() { then(embeddingJobAttemptRepository).shouldHaveNoInteractions(); then(indexingEventRepository).shouldHaveNoInteractions(); then(failureTransitionService).shouldHaveNoInteractions(); + then(applicationEventPublisher).shouldHaveNoInteractions(); } @Test diff --git a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java index cc4d098f..4f801a03 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java @@ -27,6 +27,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import org.springframework.context.ApplicationEventPublisher; import com.opensource.docgrid.domain.rag.dto.OllamaGenerateResult; import com.opensource.docgrid.domain.rag.dto.RagAnswer; @@ -43,6 +44,8 @@ import com.opensource.docgrid.domain.search.service.query.SearchConversationQueryService; import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; +import com.opensource.docgrid.global.observability.RagJobCompletionMetricEvent; +import com.opensource.docgrid.global.observability.RagJobCompletionMetricEvent.Outcome; import jakarta.persistence.EntityManager; @@ -82,6 +85,9 @@ class RagFacadeTest { @Mock private EntityManager entityManager; + @Mock + private ApplicationEventPublisher applicationEventPublisher; + private static final Long QUERY_ID = 100L; private static final Long CONVERSATION_ID = 50L; private static final Long JOB_ID = 999L; @@ -107,6 +113,8 @@ void enqueue_noQualifiedCandidates_returnsDoneWithFixedAnswer() { then(promptBuilder).should(never()).build(anyString(), any(), any()); then(ragResponseCommandService).should(times(1)).createNoContext(queryRef); then(ragResponseCommandService).should(never()).createPending(any(), anyString()); + then(applicationEventPublisher).should() + .publishEvent(new RagJobCompletionMetricEvent(Outcome.NO_CONTEXT)); } @Test @@ -241,6 +249,8 @@ void processJob_success_savesResponseAndCitations() { then(ragResponseCommandService).should(times(1)).completeSuccess(eq(job), any()); then(responseCitationCommandService).should(times(1)).saveAll(eq(job), any(), eq(List.of(searchResult))); then(ragResponseCommandService).should(never()).completeFailed(any(), anyString(), anyString()); + then(applicationEventPublisher).should() + .publishEvent(new RagJobCompletionMetricEvent(Outcome.SUCCESS)); } @Test @@ -275,6 +285,7 @@ void processJob_completeSuccessLosesRace_skipsCitationsAndReturnsFalse() { assertThat(completed).isFalse(); then(responseCitationCommandService).should(never()).saveAll(any(), any(), any()); then(searchResultRepository).should(never()).findByQuery_IdOrderByRankNo(any()); + then(applicationEventPublisher).shouldHaveNoInteractions(); } @Test @@ -299,6 +310,8 @@ void processJob_ollamaFails_savesFailedWithExtractiveFallback() { eq(job), argThatFallbackContains("AI 답변 생성이 지연", "청크 내용", "인사규정"), anyString() ); then(responseCitationCommandService).should(never()).saveAll(any(), any(), any()); + then(applicationEventPublisher).should() + .publishEvent(new RagJobCompletionMetricEvent(Outcome.PROVIDER_FALLBACK)); } @Test @@ -315,6 +328,7 @@ void processJob_completeFailedLosesRace_returnsFalse() { boolean completed = ragFacade.processJob(JOB_ID); assertThat(completed).isFalse(); + then(applicationEventPublisher).shouldHaveNoInteractions(); } @Test @@ -379,6 +393,8 @@ void failIfStillProcessing_withCandidates_forceFailsWithFallbackAndReturnsTrue() then(ragResponseRepository).should(times(1)).forceFailIfProcessing( eq(JOB_ID), argThatFallbackContains("AI 답변 생성이 지연", "청크 내용", "인사규정"), anyString() ); + then(applicationEventPublisher).should() + .publishEvent(new RagJobCompletionMetricEvent(Outcome.TIMEOUT_SWEPT)); } @Test @@ -391,6 +407,7 @@ void failIfStillProcessing_alreadyFinishedByWorker_returnsFalse() { boolean result = ragFacade.failIfStillProcessing(JOB_ID, QUERY_ID); assertThat(result).isFalse(); + then(applicationEventPublisher).shouldHaveNoInteractions(); } @Test diff --git a/backend/src/test/java/com/opensource/docgrid/domain/sync/service/command/SyncEventDispatchServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/sync/service/command/SyncEventDispatchServiceTest.java index e13ceaf0..f2b18e57 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/sync/service/command/SyncEventDispatchServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/sync/service/command/SyncEventDispatchServiceTest.java @@ -21,6 +21,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import com.opensource.docgrid.domain.sync.dto.ClaimedSyncEvent; import com.opensource.docgrid.domain.sync.entity.SyncOutboxEvent; @@ -29,6 +30,8 @@ import com.opensource.docgrid.domain.sync.enums.SyncEventType; import com.opensource.docgrid.domain.sync.repository.SyncOutboxEventRepository; import com.opensource.docgrid.domain.sync.service.SyncEventHandlerRegistry; +import com.opensource.docgrid.global.observability.SyncEventAttemptMetricEvent; +import com.opensource.docgrid.global.observability.SyncEventAttemptMetricEvent.Outcome; /** * Handler 실행과 Outbox Event 완료 전이가 같은 Dispatch 경계에서 수행되는지 검증한다. @@ -43,6 +46,7 @@ class SyncEventDispatchServiceTest { @Mock private SyncOutboxEventRepository syncOutboxEventRepository; @Mock private SyncEventHandlerRegistry syncEventHandlerRegistry; @Mock private SyncEventDeliveryAttemptService syncEventDeliveryAttemptService; + @Mock private ApplicationEventPublisher applicationEventPublisher; private SyncEventDispatchService service; private SyncOutboxEvent event; @@ -56,7 +60,8 @@ void setUp() { syncOutboxEventRepository, syncEventHandlerRegistry, syncEventDeliveryAttemptService, - clock + clock, + applicationEventPublisher ); LocalDateTime now = LocalDateTime.ofInstant(NOW, ZONE_ID); UUID eventId = UUID.randomUUID(); @@ -85,6 +90,8 @@ void dispatch_reloadsAndCompletesEvent_afterHandlerSucceeds() { assertThat(event.getStatus()).isEqualTo(SyncEventStatus.PROCESSING); assertThat(completionEvent.getStatus()).isEqualTo(SyncEventStatus.PROCESSED); assertThat(completionEvent.getProcessedAt()).isNotNull(); + then(applicationEventPublisher).should() + .publishEvent(new SyncEventAttemptMetricEvent(Outcome.PROCESSED)); } @Test @@ -102,6 +109,7 @@ void dispatch_doesNotComplete_whenHandlerFails() { ); assertThat(event.getStatus()).isEqualTo(SyncEventStatus.PROCESSING); assertThat(event.getProcessedAt()).isNull(); + then(applicationEventPublisher).shouldHaveNoInteractions(); } private SyncOutboxEvent event(UUID eventId, LocalDateTime now) { diff --git a/backend/src/test/java/com/opensource/docgrid/global/observability/AsyncPipelineMetricsAfterCommitIntegrationTest.java b/backend/src/test/java/com/opensource/docgrid/global/observability/AsyncPipelineMetricsAfterCommitIntegrationTest.java new file mode 100644 index 00000000..1b60b8e3 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/global/observability/AsyncPipelineMetricsAfterCommitIntegrationTest.java @@ -0,0 +1,101 @@ +package com.opensource.docgrid.global.observability; + +import static org.assertj.core.api.Assertions.assertThat; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.support.TransactionTemplate; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; + +/** + * 상태 전이 이벤트가 실제 Transaction 커밋 뒤에만 Counter에 반영되는지 검증한다. + */ +@SpringJUnitConfig(AsyncPipelineMetricsAfterCommitIntegrationTest.Config.class) +@DisplayName("비동기 파이프라인 Counter AFTER_COMMIT 통합 테스트") +class AsyncPipelineMetricsAfterCommitIntegrationTest { + + @Autowired private ApplicationEventPublisher applicationEventPublisher; + @Autowired private PlatformTransactionManager transactionManager; + @Autowired private SimpleMeterRegistry meterRegistry; + + private TransactionTemplate transactionTemplate; + + @BeforeEach + void setUp() { + transactionTemplate = new TransactionTemplate(transactionManager); + meterRegistry.clear(); + } + + @Test + @DisplayName("이벤트를 발행한 Transaction이 커밋되면 Counter가 한 번 증가한다") + void incrementsCounterAfterCommit() { + transactionTemplate.executeWithoutResult(status -> + applicationEventPublisher.publishEvent(EmbeddingJobAttemptMetricEvent.success()) + ); + + assertThat(successCounter()).isEqualTo(1.0); + } + + @Test + @DisplayName("이벤트를 발행한 Transaction이 롤백되면 Counter가 증가하지 않는다") + void doesNotIncrementCounterAfterRollback() { + transactionTemplate.executeWithoutResult(status -> { + applicationEventPublisher.publishEvent(EmbeddingJobAttemptMetricEvent.success()); + status.setRollbackOnly(); + }); + + assertThat(meterRegistry.find("docgrid.embedding.job.attempts").counter()).isNull(); + } + + private double successCounter() { + return meterRegistry.get("docgrid.embedding.job.attempts") + .tag("outcome", "success") + .tag("failure_type", "NONE") + .tag("retryable", "false") + .counter() + .count(); + } + + /** + * 운영 Metrics 구독자와 실제 Spring Transaction 이벤트 처리만 격리해 구성한다. + */ + @Configuration + @EnableTransactionManagement + @Import(AsyncPipelineMetrics.class) + static class Config { + + @Bean(destroyMethod = "shutdown") + EmbeddedDatabase dataSource() { + return new EmbeddedDatabaseBuilder() + .setType(EmbeddedDatabaseType.H2) + .generateUniqueName(true) + .build(); + } + + @Bean + PlatformTransactionManager transactionManager(DataSource dataSource) { + return new DataSourceTransactionManager(dataSource); + } + + @Bean + SimpleMeterRegistry meterRegistry() { + return new SimpleMeterRegistry(); + } + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/global/observability/AsyncPipelineMetricsTest.java b/backend/src/test/java/com/opensource/docgrid/global/observability/AsyncPipelineMetricsTest.java new file mode 100644 index 00000000..4cc3e24e --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/global/observability/AsyncPipelineMetricsTest.java @@ -0,0 +1,87 @@ +package com.opensource.docgrid.global.observability; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; +import com.opensource.docgrid.domain.embedding.enums.IndexingFailureType; +import com.opensource.docgrid.global.observability.RagJobCompletionMetricEvent.Outcome; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; + +/** + * 비동기 파이프라인 이벤트가 정해진 이름과 제한된 label의 Counter로 변환되는지 검증한다. + */ +@DisplayName("비동기 파이프라인 상태 전이 Counter 테스트") +class AsyncPipelineMetricsTest { + + private final SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + private final AsyncPipelineMetrics metrics = new AsyncPipelineMetrics(meterRegistry); + + @Test + @DisplayName("Embedding 재시도와 사용자 입력 최종 실패를 failure_type과 retryable로 구분한다") + void recordsEmbeddingOutcomesWithBoundedFailureLabels() { + metrics.recordEmbeddingAttempt(EmbeddingJobAttemptMetricEvent.failure( + EmbeddingJobStatus.PENDING, + IndexingFailureType.EMBEDDING_PROVIDER_TIMEOUT + )); + metrics.recordEmbeddingAttempt(EmbeddingJobAttemptMetricEvent.failure( + EmbeddingJobStatus.FAILED, + IndexingFailureType.DOCUMENT_CONTENT_INVALID + )); + + assertThat(embeddingCounter("retry_scheduled", "EMBEDDING_PROVIDER_TIMEOUT", "true")) + .isEqualTo(1.0); + assertThat(embeddingCounter("terminal_failure", "DOCUMENT_CONTENT_INVALID", "false")) + .isEqualTo(1.0); + } + + @Test + @DisplayName("RAG fallback과 timeout을 서로 다른 완료 결과로 기록한다") + void recordsRagCompletionPathsSeparately() { + metrics.recordRagCompletion(new RagJobCompletionMetricEvent(Outcome.PROVIDER_FALLBACK)); + metrics.recordRagCompletion(new RagJobCompletionMetricEvent(Outcome.TIMEOUT_SWEPT)); + + assertThat(ragCounter("provider_fallback")).isEqualTo(1.0); + assertThat(ragCounter("timeout_swept")).isEqualTo(1.0); + } + + @Test + @DisplayName("Outbox 재시도와 최종 실패를 서로 다른 처리 결과로 기록한다") + void recordsSyncAttemptOutcomesSeparately() { + metrics.recordSyncAttempt(new SyncEventAttemptMetricEvent( + SyncEventAttemptMetricEvent.Outcome.RETRY_SCHEDULED + )); + metrics.recordSyncAttempt(new SyncEventAttemptMetricEvent( + SyncEventAttemptMetricEvent.Outcome.TERMINAL_FAILURE + )); + + assertThat(syncCounter("retry_scheduled")).isEqualTo(1.0); + assertThat(syncCounter("terminal_failure")).isEqualTo(1.0); + } + + private double embeddingCounter(String outcome, String failureType, String retryable) { + return meterRegistry.get("docgrid.embedding.job.attempts") + .tag("outcome", outcome) + .tag("failure_type", failureType) + .tag("retryable", retryable) + .counter() + .count(); + } + + private double ragCounter(String outcome) { + return meterRegistry.get("docgrid.rag.job.completions") + .tag("outcome", outcome) + .counter() + .count(); + } + + private double syncCounter(String outcome) { + return meterRegistry.get("docgrid.sync.event.attempts") + .tag("outcome", outcome) + .counter() + .count(); + } +} diff --git a/docs/design/gimin-#332-async-pipeline-transition-metrics.md b/docs/design/gimin-#332-async-pipeline-transition-metrics.md new file mode 100644 index 00000000..1b08a861 --- /dev/null +++ b/docs/design/gimin-#332-async-pipeline-transition-metrics.md @@ -0,0 +1,97 @@ +# 비동기 Pipeline 상태 전이 메트릭 설계 (#332) + +## 문제 + +Actuator가 제공하는 JVM·HTTP·HikariCP 메트릭만으로는 DocGrid의 비동기 처리 결과를 알 수 없다. +인덱싱, RAG, Sync Outbox는 실패가 누적 상태로 남거나 재시도를 위해 다시 `PENDING`으로 돌아가므로 +현재 `FAILED` 행 수를 경보 기준으로 사용하면 새 장애와 과거 장애를 구분할 수 없다. + +운영 경보에는 일정 시간 동안 새로 확정된 상태 전이 횟수가 필요하다. 이 문서는 Counter 이름과 +label, 증가 시점, 이를 사용하는 Prometheus 규칙을 고정한다. + +## 상태 전이와 Counter의 Commit 경계 + +도메인 서비스는 DB 상태를 바꾸는 Transaction 안에서 제한된 metric event를 발행한다. +`AsyncPipelineMetrics`는 `@TransactionalEventListener(AFTER_COMMIT)`으로 이벤트를 받는다. + +```text +Transactional command + → 상태 전이와 감사 이력 저장 + → 제한된 outcome event 발행 + → commit 성공 + → AFTER_COMMIT listener + → Micrometer Counter 증가 +``` + +Transaction이 롤백되면 listener는 호출되지 않는다. 조건부 UPDATE가 경합에서 0건을 반환한 RAG +완료·실패 경로는 이벤트 자체를 발행하지 않는다. 멱등 재생도 최초 상태를 반환할 뿐 새로운 전이가 +아니므로 Counter를 다시 올리지 않는다. + +## Metric 계약 + +Micrometer 이름은 Prometheus endpoint에서 아래 `_total` Counter로 노출된다. + +| Prometheus metric | label | 허용 값 | +|---|---|---| +| `docgrid_embedding_job_attempts_total` | `outcome` | `success`, `retry_scheduled`, `terminal_failure` | +| | `failure_type` | `NONE`, `IndexingFailureType` 12종, `WORKER_LEASE_EXPIRED` | +| | `retryable` | `true`, `false` | +| `docgrid_rag_job_completions_total` | `outcome` | `success`, `no_context`, `provider_fallback`, `timeout_swept`, `unexpected_failure` | +| `docgrid_sync_event_attempts_total` | `outcome` | `processed`, `retry_scheduled`, `terminal_failure`, `lease_recovered` | + +ID, 사용자 정보, 문서명, 오류 메시지는 label에 넣지 않는다. 실패 유형과 결과는 코드의 enum에서만 +생성해 입력 크기에 따라 시계열 수가 늘어나지 않게 한다. + +## 도메인별 기록 위치 + +### Embedding + +- `DocumentIndexingCompletionService`: 새 실행이 `INDEXED`로 확정되면 `success` +- `DocumentIndexingFailureService`: 전이 후 상태가 `PENDING`이면 `retry_scheduled`, `FAILED`면 + `terminal_failure` +- `EmbeddingJobLeaseRecoveryService`: 같은 결과 구분에 `WORKER_LEASE_EXPIRED` 실패 유형 사용 + +완료·실패 요청의 멱등 재생 분기는 이벤트 발행 전에 반환한다. + +### RAG + +- 검색 후보 없음: `no_context` +- Ollama 정상 응답 저장: `success` +- Ollama 예외 뒤 extractive fallback 저장: `provider_fallback` +- Worker의 예상하지 못한 실패 확정: `unexpected_failure` +- Timeout Sweeper의 조건부 UPDATE 성공: `timeout_swept` + +RAG 이벤트는 `forceFailIfProcessing` 또는 `completeSuccessIfProcessing`의 영향 행이 1건일 때만 +발행한다. Worker와 Sweeper가 경쟁해도 승리한 한 경로만 기록된다. + +### Sync Outbox + +- Handler 부작용과 Event 완료를 함께 커밋: `processed` +- 실패 후 backoff 예약: `retry_scheduled` +- 재시도 소진: `terminal_failure` +- 만료 Lease를 다시 `PENDING`으로 회수: `lease_recovered` + +Lease 회수가 재시도 한도를 소진해 `FAILED`로 끝나면 즉시 대응할 수 있도록 +`terminal_failure`로 기록한다. + +## 경보 정책 + +| 경보 | 목적 | 오탐 방지 | +|---|---|---| +| `DocGridEmbeddingRetryableFailureRatioHigh` | 인프라성 인덱싱 실패 증가 | retryable만 포함, 10분간 최소 10회, 10% 초과가 5분 지속 | +| `DocGridRagProviderFallbackSpike` | Ollama 장애 또는 지연 증가 | 10분간 3회 이상, 1분 지속 | +| `DocGridRagTimeoutSweepSpike` | Worker 정체로 Sweeper 회수 증가 | 10분간 3회 이상, 1분 지속 | +| `DocGridSyncOutboxTerminalFailure` | 새 최종 실패 즉시 발견 | 누적 FAILED 개수 대신 15분 `increase` 사용 | + +`DOCUMENT_CONTENT_INVALID`처럼 retryable이 아닌 사용자 문서 오류는 인덱싱 운영 장애 경보에서 +제외한다. 성공 Counter만 증가하거나 Outbox가 재시도 중인 경우에도 최종 실패 경보는 발생하지 않는다. + +## 선택한 방식과 한계 + +도메인 서비스가 `MeterRegistry`를 직접 사용하면 상태 저장이 롤백돼도 이미 증가한 Counter를 되돌릴 +수 없다. 호출자에서 Transaction 반환 뒤 증가시키는 방식도 가능하지만 HTTP Controller, Scheduler, +내부 Worker마다 같은 규칙을 반복해야 한다. Transaction event를 사용하면 상태 전이와 metric event의 +결합 지점을 도메인 서비스 한 곳에 두고 실제 증가는 commit 이후로 미룰 수 있다. + +Counter는 프로세스 메모리에 있으므로 Backend 재시작 때 0부터 시작한다. Prometheus가 주기적으로 +누적 시계열을 저장하고 `rate`·`increase`에서 Counter reset을 처리한다는 운영 모델을 따른다. diff --git a/docs/test-results/gimin-#332-async-pipeline-transition-metrics.md b/docs/test-results/gimin-#332-async-pipeline-transition-metrics.md new file mode 100644 index 00000000..fd47c18f --- /dev/null +++ b/docs/test-results/gimin-#332-async-pipeline-transition-metrics.md @@ -0,0 +1,75 @@ +# 비동기 Pipeline 상태 전이 메트릭 검증 결과 (#332) + +검증일: 2026-09-13 + +## Java 검증 + +```bash +DB_PORT=55433 \ +JWT_SECRET=docgrid-observability-test-secret-key-2026-with-at-least-32-bytes \ +./gradlew test +``` + +결과: **1157 tests, 실패 0, skipped 0** + +새 검증은 다음 동작을 포함한다. + +- Embedding retryable Provider 실패와 non-retryable 문서 오류의 label 분리 +- RAG provider fallback과 timeout sweep Counter 분리 +- Sync Outbox 재시도와 최종 실패 Counter 분리 +- Transaction commit 후 Counter 증가 +- Transaction rollback 시 Counter 미생성 +- RAG 조건부 UPDATE 경합 패배 시 metric event 미발행 +- Sync Handler 실패 시 처리 성공 metric event 미발행 + +## Prometheus 설정과 규칙 + +```bash +docker run --rm --entrypoint=promtool \ + -v "$PWD/monitoring/prometheus:/etc/prometheus:ro" \ + prom/prometheus:v3.5.5 \ + check config /etc/prometheus/prometheus.yml +``` + +결과: **성공** + +- rule file 3개 로드 +- Embedding Provider 규칙 7개 +- Backend 규칙 3개 +- 비동기 Pipeline 규칙 4개 + +```bash +docker run --rm --entrypoint=promtool \ + -v "$PWD/monitoring/prometheus:/etc/prometheus:ro" \ + prom/prometheus:v3.5.5 \ + test rules \ + /etc/prometheus/tests/docgrid-backend-alerts.test.yml \ + /etc/prometheus/tests/docgrid-pipeline-alerts.test.yml +``` + +결과: **두 rule test suite 모두 성공** + +| 시나리오 | 결과 | +|---|---| +| retryable 실패 20%, 최소 표본 충족, `for: 5m` 충족 | 인덱싱 경보 발생 | +| `DOCUMENT_CONTENT_INVALID` 20% | 인덱싱 운영 경보 미발생 | +| RAG provider fallback 3회 | fallback 경보 발생 | +| RAG timeout sweep 3회 | timeout 경보 발생 | +| RAG success만 발생 | RAG 경보 미발생 | +| Outbox `retry_scheduled`만 발생 | 최종 실패 경보 미발생 | +| Outbox `terminal_failure` 발생 | 최종 실패 경보 발생 | + +## Compose 검증 + +```bash +docker compose config +docker compose --profile monitoring config +``` + +결과: **두 구성 모두 정상 렌더링** + +## 테스트 환경 참고 + +격리 checkout에는 Git 제외 `.env`가 없으므로 전체 테스트에 테스트용 `JWT_SECRET`을 명시했다. +기존 PostgreSQL 컨테이너가 Host `55433`에 연결되어 있어 `DB_PORT=55433`을 사용했다. 실제 Secret은 +출력하거나 저장하지 않았으며 테스트 전용 값만 사용했다. diff --git a/monitoring/prometheus/README.md b/monitoring/prometheus/README.md index 03745afd..7e31978f 100644 --- a/monitoring/prometheus/README.md +++ b/monitoring/prometheus/README.md @@ -54,8 +54,8 @@ scrape_configs: `8081`은 사용자 API 포트가 아닌 Management 포트다. 방화벽, Security Group, 컨테이너 network로 Prometheus와 운영자만 접근하도록 제한한다. -Backend 경보를 함께 사용하려면 `monitoring/prometheus/rules/docgrid-backend-alerts.yml`을 기존 -Prometheus의 `rule_files` 경로에 복사한다. +DocGrid 경보를 함께 사용하려면 `monitoring/prometheus/rules/`의 규칙 파일을 기존 Prometheus의 +`rule_files` 경로에 복사한다. ## 기본 Backend 경보 @@ -68,6 +68,19 @@ Prometheus의 `rule_files` 경로에 복사한다. HTTP 오류율에서는 Streamable HTTP 특성이 다른 `/mcp`를 제외한다. 이 경보들은 현재 Prometheus 화면에서 확인하며 외부 전달은 Alertmanager 설정을 추가한 뒤 활성화된다. +## 비동기 Pipeline 경보 + +| 경보 | 조건 | 지속 시간 | +|---|---|---:| +| `DocGridEmbeddingRetryableFailureRatioHigh` | 10분간 10회 이상 실행되고 retryable 실패가 10% 초과 | 5분 | +| `DocGridRagProviderFallbackSpike` | 10분간 provider fallback 3회 이상 | 1분 | +| `DocGridRagTimeoutSweepSpike` | 10분간 timeout 강제 종료 3회 이상 | 1분 | +| `DocGridSyncOutboxTerminalFailure` | 15분간 새로운 최종 실패 1회 이상 | 즉시 | + +Counter는 DB 상태 전이를 수행한 Transaction이 커밋된 뒤에만 증가한다. Embedding 실패의 +`failure_type`은 고정 enum이며 `retryable` label로 사용자 문서 오류와 운영 장애를 구분한다. +Job ID, 오류 메시지와 사용자 입력은 label에 포함하지 않는다. + ## 설정 검증 로컬에 promtool을 설치하지 않아도 고정된 Prometheus 이미지로 검사할 수 있다. @@ -83,12 +96,18 @@ docker run --rm --entrypoint=promtool \ prom/prometheus:v3.5.5 \ check rules \ /etc/prometheus/rules/embedding-provider-alerts.yml \ - /etc/prometheus/rules/docgrid-backend-alerts.yml + /etc/prometheus/rules/docgrid-backend-alerts.yml \ + /etc/prometheus/rules/docgrid-pipeline-alerts.yml docker run --rm --entrypoint=promtool \ -v "$PWD/monitoring/prometheus:/etc/prometheus:ro" \ prom/prometheus:v3.5.5 \ test rules /etc/prometheus/tests/docgrid-backend-alerts.test.yml +docker run --rm --entrypoint=promtool \ + -v "$PWD/monitoring/prometheus:/etc/prometheus:ro" \ + prom/prometheus:v3.5.5 \ + test rules /etc/prometheus/tests/docgrid-pipeline-alerts.test.yml + docker compose config ``` diff --git a/monitoring/prometheus/rules/docgrid-pipeline-alerts.yml b/monitoring/prometheus/rules/docgrid-pipeline-alerts.yml new file mode 100644 index 00000000..5eff92a7 --- /dev/null +++ b/monitoring/prometheus/rules/docgrid-pipeline-alerts.yml @@ -0,0 +1,76 @@ +groups: + - name: docgrid-async-pipelines + rules: + - alert: DocGridEmbeddingRetryableFailureRatioHigh + expr: | + ( + sum by (cluster, environment) ( + rate(docgrid_embedding_job_attempts_total{ + outcome=~"retry_scheduled|terminal_failure", + retryable="true" + }[10m]) + ) + / + clamp_min( + sum by (cluster, environment) ( + rate(docgrid_embedding_job_attempts_total[10m]) + ), + 0.001 + ) + ) > 0.10 + and + sum by (cluster, environment) ( + increase(docgrid_embedding_job_attempts_total[10m]) + ) >= 10 + for: 5m + labels: + severity: warning + service: embedding-worker + annotations: + summary: DocGrid embedding retryable failure ratio is high + description: More than 10% of at least 10 embedding attempts failed with a retryable cause during the last ten minutes. + + - alert: DocGridRagProviderFallbackSpike + expr: | + sum by (cluster, environment) ( + increase(docgrid_rag_job_completions_total{ + outcome="provider_fallback" + }[10m]) + ) >= 3 + for: 1m + labels: + severity: warning + service: rag-worker + dependency: ollama + annotations: + summary: DocGrid RAG provider fallback is increasing + description: At least three RAG jobs used the provider fallback during the last ten minutes. + + - alert: DocGridRagTimeoutSweepSpike + expr: | + sum by (cluster, environment) ( + increase(docgrid_rag_job_completions_total{ + outcome="timeout_swept" + }[10m]) + ) >= 3 + for: 1m + labels: + severity: warning + service: rag-worker + annotations: + summary: DocGrid RAG timeout recovery is increasing + description: At least three RAG jobs were force-failed by the timeout sweeper during the last ten minutes. + + - alert: DocGridSyncOutboxTerminalFailure + expr: | + sum by (cluster, environment) ( + increase(docgrid_sync_event_attempts_total{ + outcome="terminal_failure" + }[15m]) + ) > 0 + labels: + severity: critical + service: sync-outbox + annotations: + summary: A DocGrid Sync Outbox event reached terminal failure + description: At least one Sync Outbox event exhausted retries during the last fifteen minutes. diff --git a/monitoring/prometheus/tests/docgrid-pipeline-alerts.test.yml b/monitoring/prometheus/tests/docgrid-pipeline-alerts.test.yml new file mode 100644 index 00000000..e32bc93a --- /dev/null +++ b/monitoring/prometheus/tests/docgrid-pipeline-alerts.test.yml @@ -0,0 +1,98 @@ +rule_files: + - /etc/prometheus/rules/docgrid-pipeline-alerts.yml + +evaluation_interval: 1m + +tests: + - name: retryable embedding failures require ratio sample and duration + interval: 1m + input_series: + - series: 'docgrid_embedding_job_attempts_total{instance="backend-a:8081",environment="production",cluster="docgrid-production",outcome="success",failure_type="NONE",retryable="false"}' + values: '0+8x12' + - series: 'docgrid_embedding_job_attempts_total{instance="backend-a:8081",environment="production",cluster="docgrid-production",outcome="retry_scheduled",failure_type="EMBEDDING_PROVIDER_TIMEOUT",retryable="true"}' + values: '0+2x12' + alert_rule_test: + - eval_time: 5m + alertname: DocGridEmbeddingRetryableFailureRatioHigh + exp_alerts: [] + - eval_time: 6m + alertname: DocGridEmbeddingRetryableFailureRatioHigh + exp_alerts: + - exp_labels: + alertname: DocGridEmbeddingRetryableFailureRatioHigh + cluster: docgrid-production + environment: production + service: embedding-worker + severity: warning + exp_annotations: + summary: DocGrid embedding retryable failure ratio is high + description: More than 10% of at least 10 embedding attempts failed with a retryable cause during the last ten minutes. + + - name: non-retryable document failures do not trigger infrastructure alert + interval: 1m + input_series: + - series: 'docgrid_embedding_job_attempts_total{instance="backend-a:8081",environment="production",cluster="docgrid-production",outcome="success",failure_type="NONE",retryable="false"}' + values: '0+8x12' + - series: 'docgrid_embedding_job_attempts_total{instance="backend-a:8081",environment="production",cluster="docgrid-production",outcome="terminal_failure",failure_type="DOCUMENT_CONTENT_INVALID",retryable="false"}' + values: '0+2x12' + alert_rule_test: + - eval_time: 8m + alertname: DocGridEmbeddingRetryableFailureRatioHigh + exp_alerts: [] + + - name: rag fallback and timeout are evaluated independently + interval: 1m + input_series: + - series: 'docgrid_rag_job_completions_total{instance="backend-a:8081",environment="production",cluster="docgrid-production",outcome="provider_fallback"}' + values: '0+1x8' + - series: 'docgrid_rag_job_completions_total{instance="backend-b:8081",environment="healthy",cluster="docgrid-healthy",outcome="success"}' + values: '0+5x8' + - series: 'docgrid_rag_job_completions_total{instance="backend-c:8081",environment="timeout",cluster="docgrid-timeout",outcome="timeout_swept"}' + values: '0+1x8' + alert_rule_test: + - eval_time: 4m + alertname: DocGridRagProviderFallbackSpike + exp_alerts: + - exp_labels: + alertname: DocGridRagProviderFallbackSpike + cluster: docgrid-production + dependency: ollama + environment: production + service: rag-worker + severity: warning + exp_annotations: + summary: DocGrid RAG provider fallback is increasing + description: At least three RAG jobs used the provider fallback during the last ten minutes. + - eval_time: 4m + alertname: DocGridRagTimeoutSweepSpike + exp_alerts: + - exp_labels: + alertname: DocGridRagTimeoutSweepSpike + cluster: docgrid-timeout + environment: timeout + service: rag-worker + severity: warning + exp_annotations: + summary: DocGrid RAG timeout recovery is increasing + description: At least three RAG jobs were force-failed by the timeout sweeper during the last ten minutes. + + - name: outbox retries stay quiet and terminal failures alert + interval: 1m + input_series: + - series: 'docgrid_sync_event_attempts_total{instance="backend-a:8081",environment="retrying",cluster="docgrid-retrying",outcome="retry_scheduled"}' + values: '0+2x4' + - series: 'docgrid_sync_event_attempts_total{instance="backend-b:8081",environment="production",cluster="docgrid-production",outcome="terminal_failure"}' + values: '0+1x4' + alert_rule_test: + - eval_time: 1m + alertname: DocGridSyncOutboxTerminalFailure + exp_alerts: + - exp_labels: + alertname: DocGridSyncOutboxTerminalFailure + cluster: docgrid-production + environment: production + service: sync-outbox + severity: critical + exp_annotations: + summary: A DocGrid Sync Outbox event reached terminal failure + description: At least one Sync Outbox event exhausted retries during the last fifteen minutes. From f250fb0fba58ba902078ef0259698888db1aae71 Mon Sep 17 00:00:00 2001 From: GiminKim Date: Sun, 13 Sep 2026 18:53:27 +0900 Subject: [PATCH 2/6] test: cover async metric transition paths (#332) --- ...DocumentIndexingCompletionServiceTest.java | 3 ++ .../DocumentIndexingFailureServiceTest.java | 13 ++++++++ .../EmbeddingJobLeaseRecoveryServiceTest.java | 3 +- .../domain/rag/service/RagFacadeTest.java | 31 +++++++++++++++++++ ...lineMetricsAfterCommitIntegrationTest.java | 2 ++ ...-#332-async-pipeline-transition-metrics.md | 2 ++ ...-#332-async-pipeline-transition-metrics.md | 4 ++- 7 files changed, 56 insertions(+), 2 deletions(-) diff --git a/backend/src/test/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingCompletionServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingCompletionServiceTest.java index 02f703ad..707a8f32 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingCompletionServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingCompletionServiceTest.java @@ -48,6 +48,7 @@ import com.opensource.docgrid.domain.worker.repository.IndexingEventRepository; import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; +import com.opensource.docgrid.global.observability.EmbeddingJobAttemptMetricEvent; /** * 문서 인덱싱 최초 완료 Transaction의 잠금 순서, 검증, 상태 전이와 이전 검색 Set 비활성화를 검증한다. @@ -141,6 +142,8 @@ void complete_transitionsFirstVersionAtomically() { assertThat(eventCaptor.getValue().getFromStatus()).isEqualTo("EMBEDDING"); assertThat(eventCaptor.getValue().getToStatus()).isEqualTo("INDEXED"); assertThat(eventCaptor.getValue().getOccurredAt()).isEqualTo(COMPLETED_AT); + then(applicationEventPublisher).should() + .publishEvent(EmbeddingJobAttemptMetricEvent.success()); } @Test diff --git a/backend/src/test/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingFailureServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingFailureServiceTest.java index c687d133..257513b5 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingFailureServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingFailureServiceTest.java @@ -50,6 +50,7 @@ import com.opensource.docgrid.domain.worker.repository.IndexingEventRepository; import com.opensource.docgrid.global.exception.DocGridException; import com.opensource.docgrid.global.exception.ErrorCode; +import com.opensource.docgrid.global.observability.EmbeddingJobAttemptMetricEvent; /** * 인덱싱 실패 Service의 Retry·최종 종료·멱등 재생 분기와 원자 상태 변경을 검증한다. @@ -159,6 +160,12 @@ void fail_schedulesInitialRetryAndRecordsEvents() { .isEqualTo("{\"attemptId\":103,\"attemptNo\":1,\"failureType\":\"STORAGE_UNAVAILABLE\"}"); assertThat(eventCaptor.getAllValues().get(1).getMetadataJson()) .isEqualTo("{\"retryCount\":1,\"nextRetryAt\":\"2026-08-03T10:30:10\"}"); + then(applicationEventPublisher).should().publishEvent( + EmbeddingJobAttemptMetricEvent.failure( + EmbeddingJobStatus.PENDING, + IndexingFailureType.STORAGE_UNAVAILABLE + ) + ); } @Test @@ -223,6 +230,12 @@ void fail_terminatesFirstVersionForPermanentFailure() { .containsExactly(IndexingEventType.EMBEDDING_FAILED, IndexingEventType.FAILED); assertThat(eventCaptor.getAllValues().get(1).getMetadataJson()) .isEqualTo("{\"retryCount\":0,\"maxRetryCount\":3}"); + then(applicationEventPublisher).should().publishEvent( + EmbeddingJobAttemptMetricEvent.failure( + EmbeddingJobStatus.FAILED, + IndexingFailureType.EMBEDDING_RESULT_INVALID + ) + ); } @Test diff --git a/backend/src/test/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobLeaseRecoveryServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobLeaseRecoveryServiceTest.java index 5c0ddec2..f26fabf0 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobLeaseRecoveryServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobLeaseRecoveryServiceTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.never; @@ -84,7 +85,7 @@ void setUp() { ); return null; }).when(failureTransitionService).transition( - any(), any(), any(), any(), any(Boolean.class), any(), any() + any(), any(), any(), any(), anyBoolean(), any(), any() ); } diff --git a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java index 4f801a03..ce71966d 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java @@ -378,6 +378,37 @@ void processJob_phraseEmbeddedInAnswer_trimsBeforePersistingAndKeepsCitations() then(responseCitationCommandService).should(times(1)).saveAll(eq(job), any(), eq(List.of(searchResult))); } + // === markUnexpectedFailure() === + + @Test + @DisplayName("markUnexpectedFailure: PROCESSING job을 실제 종료하면 예상 밖 실패 메트릭을 발행한다") + void markUnexpectedFailure_completed_publishesMetricEvent() { + RagResponse job = RagResponse.builder().status(ResultStatus.PROCESSING).build(); + given(ragResponseRepository.findById(JOB_ID)).willReturn(Optional.of(job)); + given(ragResponseCommandService.completeFailed(job, "답변 생성 중 예상치 못한 오류가 발생했습니다.", "bug")) + .willReturn(true); + + boolean completed = ragFacade.markUnexpectedFailure(JOB_ID, "bug"); + + assertThat(completed).isTrue(); + then(applicationEventPublisher).should() + .publishEvent(new RagJobCompletionMetricEvent(Outcome.UNEXPECTED_FAILURE)); + } + + @Test + @DisplayName("markUnexpectedFailure: 조건부 종료 경합에서 지면 메트릭을 발행하지 않는다") + void markUnexpectedFailure_losesRace_doesNotPublishMetricEvent() { + RagResponse job = RagResponse.builder().status(ResultStatus.PROCESSING).build(); + given(ragResponseRepository.findById(JOB_ID)).willReturn(Optional.of(job)); + given(ragResponseCommandService.completeFailed(job, "답변 생성 중 예상치 못한 오류가 발생했습니다.", "bug")) + .willReturn(false); + + boolean completed = ragFacade.markUnexpectedFailure(JOB_ID, "bug"); + + assertThat(completed).isFalse(); + then(applicationEventPublisher).shouldHaveNoInteractions(); + } + // === failIfStillProcessing() === @Test diff --git a/backend/src/test/java/com/opensource/docgrid/global/observability/AsyncPipelineMetricsAfterCommitIntegrationTest.java b/backend/src/test/java/com/opensource/docgrid/global/observability/AsyncPipelineMetricsAfterCommitIntegrationTest.java index 1b60b8e3..9eed5027 100644 --- a/backend/src/test/java/com/opensource/docgrid/global/observability/AsyncPipelineMetricsAfterCommitIntegrationTest.java +++ b/backend/src/test/java/com/opensource/docgrid/global/observability/AsyncPipelineMetricsAfterCommitIntegrationTest.java @@ -6,6 +6,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; @@ -27,6 +28,7 @@ * 상태 전이 이벤트가 실제 Transaction 커밋 뒤에만 Counter에 반영되는지 검증한다. */ @SpringJUnitConfig(AsyncPipelineMetricsAfterCommitIntegrationTest.Config.class) +@Tag("integration") @DisplayName("비동기 파이프라인 Counter AFTER_COMMIT 통합 테스트") class AsyncPipelineMetricsAfterCommitIntegrationTest { diff --git a/docs/design/gimin-#332-async-pipeline-transition-metrics.md b/docs/design/gimin-#332-async-pipeline-transition-metrics.md index 1b08a861..bae3493e 100644 --- a/docs/design/gimin-#332-async-pipeline-transition-metrics.md +++ b/docs/design/gimin-#332-async-pipeline-transition-metrics.md @@ -1,5 +1,7 @@ # 비동기 Pipeline 상태 전이 메트릭 설계 (#332) +closes #332 + ## 문제 Actuator가 제공하는 JVM·HTTP·HikariCP 메트릭만으로는 DocGrid의 비동기 처리 결과를 알 수 없다. diff --git a/docs/test-results/gimin-#332-async-pipeline-transition-metrics.md b/docs/test-results/gimin-#332-async-pipeline-transition-metrics.md index fd47c18f..1b6f9f09 100644 --- a/docs/test-results/gimin-#332-async-pipeline-transition-metrics.md +++ b/docs/test-results/gimin-#332-async-pipeline-transition-metrics.md @@ -10,12 +10,14 @@ JWT_SECRET=docgrid-observability-test-secret-key-2026-with-at-least-32-bytes \ ./gradlew test ``` -결과: **1157 tests, 실패 0, skipped 0** +결과: **1159 tests, 실패 0, errors 0, skipped 0** 새 검증은 다음 동작을 포함한다. - Embedding retryable Provider 실패와 non-retryable 문서 오류의 label 분리 +- Embedding 성공·재시도·최종 실패 Service의 metric event 발행 - RAG provider fallback과 timeout sweep Counter 분리 +- RAG 예상 밖 실패의 실제 종료와 조건부 UPDATE 경합 패배 분기 - Sync Outbox 재시도와 최종 실패 Counter 분리 - Transaction commit 후 Counter 증가 - Transaction rollback 시 Counter 미생성 From 3bb3a9cf42bf3d00a63db1a2f555d72bee58b0e4 Mon Sep 17 00:00:00 2001 From: GiminKim Date: Sun, 13 Sep 2026 19:02:03 +0900 Subject: [PATCH 3/6] test: cover sync metric outcome branches (#332) --- .../command/SyncEventFailureServiceTest.java | 103 +++++++++++++++++ .../SyncEventLeaseRecoveryServiceTest.java | 105 ++++++++++++++++++ ...-#332-async-pipeline-transition-metrics.md | 3 +- 3 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 backend/src/test/java/com/opensource/docgrid/domain/sync/service/command/SyncEventFailureServiceTest.java create mode 100644 backend/src/test/java/com/opensource/docgrid/domain/sync/service/command/SyncEventLeaseRecoveryServiceTest.java diff --git a/backend/src/test/java/com/opensource/docgrid/domain/sync/service/command/SyncEventFailureServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/sync/service/command/SyncEventFailureServiceTest.java new file mode 100644 index 00000000..f19e2edc --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/sync/service/command/SyncEventFailureServiceTest.java @@ -0,0 +1,103 @@ +package com.opensource.docgrid.domain.sync.service.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import com.opensource.docgrid.domain.sync.entity.SyncOutboxEvent; +import com.opensource.docgrid.domain.sync.enums.SyncAggregateType; +import com.opensource.docgrid.domain.sync.enums.SyncEventStatus; +import com.opensource.docgrid.domain.sync.enums.SyncEventType; +import com.opensource.docgrid.domain.sync.repository.SyncOutboxEventRepository; +import com.opensource.docgrid.domain.sync.service.SyncEventRetrySchedule; +import com.opensource.docgrid.global.observability.SyncEventAttemptMetricEvent; +import com.opensource.docgrid.global.observability.SyncEventAttemptMetricEvent.Outcome; + +/** + * Sync Handler 실패가 Retry 또는 최종 실패로 확정될 때 대응 metric event를 발행하는지 검증한다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("SyncEventFailureService 메트릭 테스트") +class SyncEventFailureServiceTest { + + private static final UUID EVENT_ID = UUID.fromString("68c12639-7c1f-4740-8db9-29a341983251"); + private static final UUID CLAIM_TOKEN = UUID.fromString("a5492111-f005-40cf-9f2f-f72fd63f45f7"); + private static final LocalDateTime FAILED_AT = LocalDateTime.of(2026, 9, 13, 19, 0); + private static final Clock CLOCK = Clock.fixed( + Instant.parse("2026-09-13T19:00:00Z"), + ZoneOffset.UTC + ); + + @Mock private SyncOutboxEventRepository repository; + @Mock private SyncEventRetrySchedule retrySchedule; + @Mock private SyncEventDeliveryAttemptService attemptService; + @Mock private ApplicationEventPublisher applicationEventPublisher; + + @Test + @DisplayName("남은 실행 기회가 있으면 RETRY_SCHEDULED event를 발행한다") + void recordFailure_retryable_publishesRetryScheduledMetric() { + SyncOutboxEvent event = claimedEvent(3); + given(repository.findByEventIdForUpdate(EVENT_ID)).willReturn(Optional.of(event)); + given(retrySchedule.nextAvailableAt(event, FAILED_AT)).willReturn(FAILED_AT.plusSeconds(5)); + SyncEventFailureService service = service(); + + service.recordFailure(EVENT_ID, CLAIM_TOKEN, "HANDLER_ERROR", "temporary failure"); + + assertThat(event.getStatus()).isEqualTo(SyncEventStatus.PENDING); + then(applicationEventPublisher).should() + .publishEvent(new SyncEventAttemptMetricEvent(Outcome.RETRY_SCHEDULED)); + } + + @Test + @DisplayName("마지막 실행 기회를 소진하면 TERMINAL_FAILURE event를 발행한다") + void recordFailure_exhausted_publishesTerminalFailureMetric() { + SyncOutboxEvent event = claimedEvent(1); + given(repository.findByEventIdForUpdate(EVENT_ID)).willReturn(Optional.of(event)); + SyncEventFailureService service = service(); + + service.recordFailure(EVENT_ID, CLAIM_TOKEN, "HANDLER_ERROR", "permanent failure"); + + assertThat(event.getStatus()).isEqualTo(SyncEventStatus.FAILED); + then(applicationEventPublisher).should() + .publishEvent(new SyncEventAttemptMetricEvent(Outcome.TERMINAL_FAILURE)); + } + + private SyncEventFailureService service() { + return new SyncEventFailureService( + repository, + retrySchedule, + attemptService, + CLOCK, + applicationEventPublisher + ); + } + + private SyncOutboxEvent claimedEvent(int maxRetryCount) { + SyncOutboxEvent event = SyncOutboxEvent.builder() + .eventId(EVENT_ID) + .idempotencyKey("sync-failure-service-test-" + maxRetryCount) + .aggregateType(SyncAggregateType.DOCUMENT_VERSION) + .aggregateId(1L) + .eventType(SyncEventType.DOCUMENT_VERSION_CREATED) + .availableAt(FAILED_AT.minusMinutes(1)) + .occurredAt(FAILED_AT.minusMinutes(1)) + .maxRetryCount(maxRetryCount) + .build(); + event.claim("dispatcher", CLAIM_TOKEN, FAILED_AT.minusSeconds(1), FAILED_AT.plusMinutes(1)); + return event; + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/sync/service/command/SyncEventLeaseRecoveryServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/sync/service/command/SyncEventLeaseRecoveryServiceTest.java new file mode 100644 index 00000000..bb4cafb4 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/sync/service/command/SyncEventLeaseRecoveryServiceTest.java @@ -0,0 +1,105 @@ +package com.opensource.docgrid.domain.sync.service.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import com.opensource.docgrid.domain.sync.entity.SyncOutboxEvent; +import com.opensource.docgrid.domain.sync.enums.SyncAggregateType; +import com.opensource.docgrid.domain.sync.enums.SyncEventStatus; +import com.opensource.docgrid.domain.sync.enums.SyncEventType; +import com.opensource.docgrid.domain.sync.repository.SyncOutboxEventRepository; +import com.opensource.docgrid.domain.sync.service.SyncEventRetrySchedule; +import com.opensource.docgrid.global.observability.SyncEventAttemptMetricEvent; +import com.opensource.docgrid.global.observability.SyncEventAttemptMetricEvent.Outcome; + +/** + * 만료 Sync Lease 회수가 재예약과 최종 실패 결과에 맞는 metric event를 발행하는지 검증한다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("SyncEventLeaseRecoveryService 메트릭 테스트") +class SyncEventLeaseRecoveryServiceTest { + + private static final UUID EVENT_ID = UUID.fromString("8db4bc37-8148-4bb4-b471-d89722d9013f"); + private static final UUID CLAIM_TOKEN = UUID.fromString("4b65a076-0677-47b2-a21f-d3499ef1d09b"); + private static final LocalDateTime RECOVERED_AT = LocalDateTime.of(2026, 9, 13, 19, 0); + + @Mock private SyncOutboxEventRepository repository; + @Mock private SyncEventRetrySchedule retrySchedule; + @Mock private SyncEventDeliveryAttemptService attemptService; + @Mock private ApplicationEventPublisher applicationEventPublisher; + + @Test + @DisplayName("남은 실행 기회가 있는 만료 Lease는 LEASE_RECOVERED event를 발행한다") + void recover_retryable_publishesLeaseRecoveredMetric() { + SyncOutboxEvent event = expiredEvent(3); + given(repository.findExpiredByEventIdForUpdateSkipLocked(EVENT_ID, RECOVERED_AT)) + .willReturn(Optional.of(event)); + given(retrySchedule.nextAvailableAt(event, RECOVERED_AT)).willReturn(RECOVERED_AT.plusSeconds(5)); + SyncEventLeaseRecoveryService service = service(); + + SyncEventLeaseRecoveryService.RecoveryResult result = service.recover(EVENT_ID, RECOVERED_AT); + + assertThat(result.recovered()).isTrue(); + assertThat(result.status()).isEqualTo(SyncEventStatus.PENDING); + then(applicationEventPublisher).should() + .publishEvent(new SyncEventAttemptMetricEvent(Outcome.LEASE_RECOVERED)); + } + + @Test + @DisplayName("마지막 실행 기회를 소진한 만료 Lease는 TERMINAL_FAILURE event를 발행한다") + void recover_exhausted_publishesTerminalFailureMetric() { + SyncOutboxEvent event = expiredEvent(1); + given(repository.findExpiredByEventIdForUpdateSkipLocked(EVENT_ID, RECOVERED_AT)) + .willReturn(Optional.of(event)); + given(retrySchedule.nextAvailableAt(event, RECOVERED_AT)).willReturn(RECOVERED_AT.plusSeconds(5)); + SyncEventLeaseRecoveryService service = service(); + + SyncEventLeaseRecoveryService.RecoveryResult result = service.recover(EVENT_ID, RECOVERED_AT); + + assertThat(result.recovered()).isTrue(); + assertThat(result.status()).isEqualTo(SyncEventStatus.FAILED); + then(applicationEventPublisher).should() + .publishEvent(new SyncEventAttemptMetricEvent(Outcome.TERMINAL_FAILURE)); + } + + private SyncEventLeaseRecoveryService service() { + return new SyncEventLeaseRecoveryService( + repository, + retrySchedule, + attemptService, + applicationEventPublisher + ); + } + + private SyncOutboxEvent expiredEvent(int maxRetryCount) { + SyncOutboxEvent event = SyncOutboxEvent.builder() + .eventId(EVENT_ID) + .idempotencyKey("sync-lease-recovery-test-" + maxRetryCount) + .aggregateType(SyncAggregateType.DOCUMENT_VERSION) + .aggregateId(1L) + .eventType(SyncEventType.DOCUMENT_VERSION_CREATED) + .availableAt(RECOVERED_AT.minusMinutes(2)) + .occurredAt(RECOVERED_AT.minusMinutes(2)) + .maxRetryCount(maxRetryCount) + .build(); + event.claim( + "dispatcher", + CLAIM_TOKEN, + RECOVERED_AT.minusMinutes(1), + RECOVERED_AT.minusSeconds(1) + ); + return event; + } +} diff --git a/docs/test-results/gimin-#332-async-pipeline-transition-metrics.md b/docs/test-results/gimin-#332-async-pipeline-transition-metrics.md index 1b6f9f09..eb2cbc51 100644 --- a/docs/test-results/gimin-#332-async-pipeline-transition-metrics.md +++ b/docs/test-results/gimin-#332-async-pipeline-transition-metrics.md @@ -10,7 +10,7 @@ JWT_SECRET=docgrid-observability-test-secret-key-2026-with-at-least-32-bytes \ ./gradlew test ``` -결과: **1159 tests, 실패 0, errors 0, skipped 0** +결과: **1163 tests, 실패 0, errors 0, skipped 0** 새 검증은 다음 동작을 포함한다. @@ -19,6 +19,7 @@ JWT_SECRET=docgrid-observability-test-secret-key-2026-with-at-least-32-bytes \ - RAG provider fallback과 timeout sweep Counter 분리 - RAG 예상 밖 실패의 실제 종료와 조건부 UPDATE 경합 패배 분기 - Sync Outbox 재시도와 최종 실패 Counter 분리 +- Sync Outbox 실패·Lease 회수의 재예약과 최종 실패 event 분기 - Transaction commit 후 Counter 증가 - Transaction rollback 시 Counter 미생성 - RAG 조건부 UPDATE 경합 패배 시 metric event 미발행 From f241278c5413f8952aed703397c882a5c48a71a9 Mon Sep 17 00:00:00 2001 From: GiminKim Date: Sun, 13 Sep 2026 19:12:51 +0900 Subject: [PATCH 4/6] fix: keep embedding metric mapping compile-time safe (#332) --- .../observability/AsyncPipelineMetrics.java | 2 +- .../EmbeddingJobAttemptMetricEvent.java | 72 +++++++++++-------- .../EmbeddingJobLeaseRecoveryServiceTest.java | 27 ++++++- ...-#332-async-pipeline-transition-metrics.md | 4 +- ...-#332-async-pipeline-transition-metrics.md | 4 +- 5 files changed, 76 insertions(+), 33 deletions(-) diff --git a/backend/src/main/java/com/opensource/docgrid/global/observability/AsyncPipelineMetrics.java b/backend/src/main/java/com/opensource/docgrid/global/observability/AsyncPipelineMetrics.java index ac5f86f0..3222604c 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/observability/AsyncPipelineMetrics.java +++ b/backend/src/main/java/com/opensource/docgrid/global/observability/AsyncPipelineMetrics.java @@ -32,7 +32,7 @@ public void recordEmbeddingAttempt(EmbeddingJobAttemptMetricEvent event) { EMBEDDING_ATTEMPTS, "outcome", event.outcome().label(), "failure_type", event.failureType().name(), - "retryable", Boolean.toString(event.failureType().isRetryable()) + "retryable", Boolean.toString(event.retryable()) ).increment(); } diff --git a/backend/src/main/java/com/opensource/docgrid/global/observability/EmbeddingJobAttemptMetricEvent.java b/backend/src/main/java/com/opensource/docgrid/global/observability/EmbeddingJobAttemptMetricEvent.java index e14eb212..4c38df75 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/observability/EmbeddingJobAttemptMetricEvent.java +++ b/backend/src/main/java/com/opensource/docgrid/global/observability/EmbeddingJobAttemptMetricEvent.java @@ -12,14 +12,15 @@ */ public record EmbeddingJobAttemptMetricEvent( Outcome outcome, - FailureType failureType + FailureType failureType, + boolean retryable ) { /** * 성공한 실행의 단일 label 조합을 생성한다. */ public static EmbeddingJobAttemptMetricEvent success() { - return new EmbeddingJobAttemptMetricEvent(Outcome.SUCCESS, FailureType.NONE); + return new EmbeddingJobAttemptMetricEvent(Outcome.SUCCESS, FailureType.NONE, false); } /** @@ -29,9 +30,14 @@ public static EmbeddingJobAttemptMetricEvent failure( EmbeddingJobStatus status, IndexingFailureType failureType ) { + IndexingFailureType requiredFailureType = Objects.requireNonNull( + failureType, + "failureType은 필수입니다." + ); return new EmbeddingJobAttemptMetricEvent( Outcome.fromFailureStatus(status), - FailureType.from(failureType) + FailureType.from(requiredFailureType), + requiredFailureType.isRetryable() ); } @@ -41,7 +47,8 @@ public static EmbeddingJobAttemptMetricEvent failure( public static EmbeddingJobAttemptMetricEvent leaseExpired(EmbeddingJobStatus status) { return new EmbeddingJobAttemptMetricEvent( Outcome.fromFailureStatus(status), - FailureType.WORKER_LEASE_EXPIRED + FailureType.WORKER_LEASE_EXPIRED, + true ); } @@ -54,6 +61,9 @@ public static EmbeddingJobAttemptMetricEvent leaseExpired(EmbeddingJobStatus sta if ((outcome == Outcome.SUCCESS) != (failureType == FailureType.NONE)) { throw new IllegalArgumentException("성공 결과만 NONE 실패 유형을 사용할 수 있습니다."); } + if (outcome == Outcome.SUCCESS && retryable) { + throw new IllegalArgumentException("성공 결과는 retryable일 수 없습니다."); + } } /** @@ -87,33 +97,37 @@ private static Outcome fromFailureStatus(EmbeddingJobStatus status) { * 외부 요청 enum과 내부 Lease 회수 원인을 합친 고정 실패 label 목록이다. */ public enum FailureType { - NONE(false), - STORAGE_UNAVAILABLE(true), - STORAGE_CONFIGURATION_INVALID(false), - STORAGE_OBJECT_MISSING(false), - DOCUMENT_CONTENT_INVALID(false), - EMBEDDING_PROVIDER_UNAVAILABLE(true), - EMBEDDING_PROVIDER_OVERLOADED(true), - EMBEDDING_PROVIDER_TIMEOUT(true), - EMBEDDING_PROVIDER_CIRCUIT_OPEN(true), - EMBEDDING_REQUEST_INVALID(false), - EMBEDDING_RESULT_INVALID(false), - INDEXING_STATE_INCONSISTENT(false), - WORKER_INTERNAL_ERROR(true), - WORKER_LEASE_EXPIRED(true); - - private final boolean retryable; - - FailureType(boolean retryable) { - this.retryable = retryable; - } - - public boolean isRetryable() { - return retryable; - } + NONE, + STORAGE_UNAVAILABLE, + STORAGE_CONFIGURATION_INVALID, + STORAGE_OBJECT_MISSING, + DOCUMENT_CONTENT_INVALID, + EMBEDDING_PROVIDER_UNAVAILABLE, + EMBEDDING_PROVIDER_OVERLOADED, + EMBEDDING_PROVIDER_TIMEOUT, + EMBEDDING_PROVIDER_CIRCUIT_OPEN, + EMBEDDING_REQUEST_INVALID, + EMBEDDING_RESULT_INVALID, + INDEXING_STATE_INCONSISTENT, + WORKER_INTERNAL_ERROR, + WORKER_LEASE_EXPIRED; private static FailureType from(IndexingFailureType failureType) { - return valueOf(Objects.requireNonNull(failureType, "failureType은 필수입니다.").name()); + // 도메인 enum이 늘어나면 이 switch가 컴파일 오류를 내므로 런타임 실패 경로를 막는다. + return switch (failureType) { + case STORAGE_UNAVAILABLE -> STORAGE_UNAVAILABLE; + case STORAGE_CONFIGURATION_INVALID -> STORAGE_CONFIGURATION_INVALID; + case STORAGE_OBJECT_MISSING -> STORAGE_OBJECT_MISSING; + case DOCUMENT_CONTENT_INVALID -> DOCUMENT_CONTENT_INVALID; + case EMBEDDING_PROVIDER_UNAVAILABLE -> EMBEDDING_PROVIDER_UNAVAILABLE; + case EMBEDDING_PROVIDER_OVERLOADED -> EMBEDDING_PROVIDER_OVERLOADED; + case EMBEDDING_PROVIDER_TIMEOUT -> EMBEDDING_PROVIDER_TIMEOUT; + case EMBEDDING_PROVIDER_CIRCUIT_OPEN -> EMBEDDING_PROVIDER_CIRCUIT_OPEN; + case EMBEDDING_REQUEST_INVALID -> EMBEDDING_REQUEST_INVALID; + case EMBEDDING_RESULT_INVALID -> EMBEDDING_RESULT_INVALID; + case INDEXING_STATE_INCONSISTENT -> INDEXING_STATE_INCONSISTENT; + case WORKER_INTERNAL_ERROR -> WORKER_INTERNAL_ERROR; + }; } } } diff --git a/backend/src/test/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobLeaseRecoveryServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobLeaseRecoveryServiceTest.java index f26fabf0..56daf575 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobLeaseRecoveryServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobLeaseRecoveryServiceTest.java @@ -6,8 +6,9 @@ import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; -import static org.mockito.Mockito.never; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; import java.time.Duration; import java.time.LocalDateTime; @@ -155,6 +156,30 @@ void recover_transitionsJobWithoutCreatingAttempt_when_attemptDoesNotExist() { .publishEvent(EmbeddingJobAttemptMetricEvent.leaseExpired(EmbeddingJobStatus.PENDING)); } + @Test + @DisplayName("Lease 만료 재시도를 모두 소진하면 최종 실패 결과를 계측한다") + void recover_publishesTerminalFailureMetric_when_retryIsExhausted() { + EmbeddingJob embeddingJob = createExpiredJob(); + EmbeddingJobAttempt attempt = createAttempt(embeddingJob, AttemptStatus.STARTED); + given(embeddingJobRepository.findExpiredByIdForUpdateSkipLocked(JOB_ID, RECOVERED_AT)) + .willReturn(Optional.of(embeddingJob)); + given(embeddingJobAttemptRepository.findByEmbeddingJobIdAndClaimToken(JOB_ID, CLAIM_TOKEN)) + .willReturn(Optional.of(attempt)); + doAnswer(invocation -> { + ReflectionTestUtils.setField(embeddingJob, "status", EmbeddingJobStatus.FAILED); + return null; + }).when(failureTransitionService).transition( + any(), any(), any(), any(), anyBoolean(), any(), any() + ); + + RecoveryResult result = recoveryService.recover(JOB_ID, RECOVERED_AT); + + assertThat(result.recovered()).isTrue(); + assertThat(result.status()).isEqualTo(EmbeddingJobStatus.FAILED); + then(applicationEventPublisher).should() + .publishEvent(EmbeddingJobAttemptMetricEvent.leaseExpired(EmbeddingJobStatus.FAILED)); + } + @Test @DisplayName("후보가 이미 갱신·완료됐거나 다른 Scheduler가 잠갔으면 상태 변경 없이 Skip한다") void recover_skips_when_candidateCannotBeLocked() { diff --git a/docs/design/gimin-#332-async-pipeline-transition-metrics.md b/docs/design/gimin-#332-async-pipeline-transition-metrics.md index bae3493e..70952493 100644 --- a/docs/design/gimin-#332-async-pipeline-transition-metrics.md +++ b/docs/design/gimin-#332-async-pipeline-transition-metrics.md @@ -42,7 +42,9 @@ Micrometer 이름은 Prometheus endpoint에서 아래 `_total` Counter로 노출 | `docgrid_sync_event_attempts_total` | `outcome` | `processed`, `retry_scheduled`, `terminal_failure`, `lease_recovered` | ID, 사용자 정보, 문서명, 오류 메시지는 label에 넣지 않는다. 실패 유형과 결과는 코드의 enum에서만 -생성해 입력 크기에 따라 시계열 수가 늘어나지 않게 한다. +생성해 입력 크기에 따라 시계열 수가 늘어나지 않게 한다. `failure_type`은 `IndexingFailureType`의 +모든 값을 exhaustive switch로 매핑하고, `retryable`은 도메인 enum의 정책값을 그대로 사용한다. +실패 유형 추가 시 누락은 컴파일 오류가 되고 Retry 정책은 한 곳에서만 관리된다. ## 도메인별 기록 위치 diff --git a/docs/test-results/gimin-#332-async-pipeline-transition-metrics.md b/docs/test-results/gimin-#332-async-pipeline-transition-metrics.md index eb2cbc51..c1ee6512 100644 --- a/docs/test-results/gimin-#332-async-pipeline-transition-metrics.md +++ b/docs/test-results/gimin-#332-async-pipeline-transition-metrics.md @@ -10,12 +10,14 @@ JWT_SECRET=docgrid-observability-test-secret-key-2026-with-at-least-32-bytes \ ./gradlew test ``` -결과: **1163 tests, 실패 0, errors 0, skipped 0** +결과: **1164 tests, 실패 0, errors 0, skipped 0** 새 검증은 다음 동작을 포함한다. - Embedding retryable Provider 실패와 non-retryable 문서 오류의 label 분리 - Embedding 성공·재시도·최종 실패 Service의 metric event 발행 +- Embedding Lease 회수의 재예약·재시도 소진 최종 실패 event 분기 +- 도메인 실패 유형의 exhaustive metric label 매핑과 Retry 정책 재사용 - RAG provider fallback과 timeout sweep Counter 분리 - RAG 예상 밖 실패의 실제 종료와 조건부 UPDATE 경합 패배 분기 - Sync Outbox 재시도와 최종 실패 Counter 분리 From 43bbb69a44ba3f136721f5e26b34e29bce12a46e Mon Sep 17 00:00:00 2001 From: GiminKim Date: Sun, 13 Sep 2026 19:23:22 +0900 Subject: [PATCH 5/6] fix: derive embedding retryability from domain policy (#332) --- .../observability/AsyncPipelineMetrics.java | 2 +- .../EmbeddingJobAttemptMetricEvent.java | 54 ++++++++++--------- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/backend/src/main/java/com/opensource/docgrid/global/observability/AsyncPipelineMetrics.java b/backend/src/main/java/com/opensource/docgrid/global/observability/AsyncPipelineMetrics.java index 3222604c..ac5f86f0 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/observability/AsyncPipelineMetrics.java +++ b/backend/src/main/java/com/opensource/docgrid/global/observability/AsyncPipelineMetrics.java @@ -32,7 +32,7 @@ public void recordEmbeddingAttempt(EmbeddingJobAttemptMetricEvent event) { EMBEDDING_ATTEMPTS, "outcome", event.outcome().label(), "failure_type", event.failureType().name(), - "retryable", Boolean.toString(event.retryable()) + "retryable", Boolean.toString(event.failureType().isRetryable()) ).increment(); } diff --git a/backend/src/main/java/com/opensource/docgrid/global/observability/EmbeddingJobAttemptMetricEvent.java b/backend/src/main/java/com/opensource/docgrid/global/observability/EmbeddingJobAttemptMetricEvent.java index 4c38df75..0a663839 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/observability/EmbeddingJobAttemptMetricEvent.java +++ b/backend/src/main/java/com/opensource/docgrid/global/observability/EmbeddingJobAttemptMetricEvent.java @@ -12,15 +12,14 @@ */ public record EmbeddingJobAttemptMetricEvent( Outcome outcome, - FailureType failureType, - boolean retryable + FailureType failureType ) { /** * 성공한 실행의 단일 label 조합을 생성한다. */ public static EmbeddingJobAttemptMetricEvent success() { - return new EmbeddingJobAttemptMetricEvent(Outcome.SUCCESS, FailureType.NONE, false); + return new EmbeddingJobAttemptMetricEvent(Outcome.SUCCESS, FailureType.NONE); } /** @@ -36,8 +35,7 @@ public static EmbeddingJobAttemptMetricEvent failure( ); return new EmbeddingJobAttemptMetricEvent( Outcome.fromFailureStatus(status), - FailureType.from(requiredFailureType), - requiredFailureType.isRetryable() + FailureType.from(requiredFailureType) ); } @@ -47,8 +45,7 @@ public static EmbeddingJobAttemptMetricEvent failure( public static EmbeddingJobAttemptMetricEvent leaseExpired(EmbeddingJobStatus status) { return new EmbeddingJobAttemptMetricEvent( Outcome.fromFailureStatus(status), - FailureType.WORKER_LEASE_EXPIRED, - true + FailureType.WORKER_LEASE_EXPIRED ); } @@ -61,9 +58,6 @@ public static EmbeddingJobAttemptMetricEvent leaseExpired(EmbeddingJobStatus sta if ((outcome == Outcome.SUCCESS) != (failureType == FailureType.NONE)) { throw new IllegalArgumentException("성공 결과만 NONE 실패 유형을 사용할 수 있습니다."); } - if (outcome == Outcome.SUCCESS && retryable) { - throw new IllegalArgumentException("성공 결과는 retryable일 수 없습니다."); - } } /** @@ -97,20 +91,32 @@ private static Outcome fromFailureStatus(EmbeddingJobStatus status) { * 외부 요청 enum과 내부 Lease 회수 원인을 합친 고정 실패 label 목록이다. */ public enum FailureType { - NONE, - STORAGE_UNAVAILABLE, - STORAGE_CONFIGURATION_INVALID, - STORAGE_OBJECT_MISSING, - DOCUMENT_CONTENT_INVALID, - EMBEDDING_PROVIDER_UNAVAILABLE, - EMBEDDING_PROVIDER_OVERLOADED, - EMBEDDING_PROVIDER_TIMEOUT, - EMBEDDING_PROVIDER_CIRCUIT_OPEN, - EMBEDDING_REQUEST_INVALID, - EMBEDDING_RESULT_INVALID, - INDEXING_STATE_INCONSISTENT, - WORKER_INTERNAL_ERROR, - WORKER_LEASE_EXPIRED; + NONE(null), + STORAGE_UNAVAILABLE(IndexingFailureType.STORAGE_UNAVAILABLE), + STORAGE_CONFIGURATION_INVALID(IndexingFailureType.STORAGE_CONFIGURATION_INVALID), + STORAGE_OBJECT_MISSING(IndexingFailureType.STORAGE_OBJECT_MISSING), + DOCUMENT_CONTENT_INVALID(IndexingFailureType.DOCUMENT_CONTENT_INVALID), + EMBEDDING_PROVIDER_UNAVAILABLE(IndexingFailureType.EMBEDDING_PROVIDER_UNAVAILABLE), + EMBEDDING_PROVIDER_OVERLOADED(IndexingFailureType.EMBEDDING_PROVIDER_OVERLOADED), + EMBEDDING_PROVIDER_TIMEOUT(IndexingFailureType.EMBEDDING_PROVIDER_TIMEOUT), + EMBEDDING_PROVIDER_CIRCUIT_OPEN(IndexingFailureType.EMBEDDING_PROVIDER_CIRCUIT_OPEN), + EMBEDDING_REQUEST_INVALID(IndexingFailureType.EMBEDDING_REQUEST_INVALID), + EMBEDDING_RESULT_INVALID(IndexingFailureType.EMBEDDING_RESULT_INVALID), + INDEXING_STATE_INCONSISTENT(IndexingFailureType.INDEXING_STATE_INCONSISTENT), + WORKER_INTERNAL_ERROR(IndexingFailureType.WORKER_INTERNAL_ERROR), + WORKER_LEASE_EXPIRED(null); + + private final IndexingFailureType indexingFailureType; + + FailureType(IndexingFailureType indexingFailureType) { + this.indexingFailureType = indexingFailureType; + } + + /** Retry 정책은 도메인 enum을 단일 출처로 사용하고 내부 Lease 원인만 고정한다. */ + public boolean isRetryable() { + return this == WORKER_LEASE_EXPIRED + || (indexingFailureType != null && indexingFailureType.isRetryable()); + } private static FailureType from(IndexingFailureType failureType) { // 도메인 enum이 늘어나면 이 switch가 컴파일 오류를 내므로 런타임 실패 경로를 막는다. From e7bf8a9ebbd5fb68144702d72b5fa19ee6d9ea13 Mon Sep 17 00:00:00 2001 From: GiminKim Date: Sun, 13 Sep 2026 19:38:03 +0900 Subject: [PATCH 6/6] test: assert metric commit and alert timing boundaries (#332) --- ...lineMetricsAfterCommitIntegrationTest.java | 23 ++++++++++++++++--- ...-#332-async-pipeline-transition-metrics.md | 2 +- .../tests/docgrid-pipeline-alerts.test.yml | 6 +++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/backend/src/test/java/com/opensource/docgrid/global/observability/AsyncPipelineMetricsAfterCommitIntegrationTest.java b/backend/src/test/java/com/opensource/docgrid/global/observability/AsyncPipelineMetricsAfterCommitIntegrationTest.java index 9eed5027..1496bc77 100644 --- a/backend/src/test/java/com/opensource/docgrid/global/observability/AsyncPipelineMetricsAfterCommitIntegrationTest.java +++ b/backend/src/test/java/com/opensource/docgrid/global/observability/AsyncPipelineMetricsAfterCommitIntegrationTest.java @@ -2,6 +2,8 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.util.concurrent.atomic.AtomicBoolean; + import javax.sql.DataSource; import org.junit.jupiter.api.BeforeEach; @@ -20,6 +22,8 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionTemplate; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; @@ -47,10 +51,23 @@ void setUp() { @Test @DisplayName("이벤트를 발행한 Transaction이 커밋되면 Counter가 한 번 증가한다") void incrementsCounterAfterCommit() { - transactionTemplate.executeWithoutResult(status -> - applicationEventPublisher.publishEvent(EmbeddingJobAttemptMetricEvent.success()) - ); + AtomicBoolean beforeCommitObserved = new AtomicBoolean(); + transactionTemplate.executeWithoutResult(status -> { + applicationEventPublisher.publishEvent(EmbeddingJobAttemptMetricEvent.success()); + + // 1. Event 발행 직후에는 아직 commit이 아니므로 Counter가 생성되지 않아야 한다. + assertThat(meterRegistry.find("docgrid.embedding.job.attempts").counter()).isNull(); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void beforeCommit(boolean readOnly) { + // 2. BEFORE_COMMIT phase에서도 값이 없음을 확인해 listener phase 회귀를 잡는다. + assertThat(meterRegistry.find("docgrid.embedding.job.attempts").counter()).isNull(); + beforeCommitObserved.set(true); + } + }); + }); + assertThat(beforeCommitObserved).isTrue(); assertThat(successCounter()).isEqualTo(1.0); } diff --git a/docs/test-results/gimin-#332-async-pipeline-transition-metrics.md b/docs/test-results/gimin-#332-async-pipeline-transition-metrics.md index c1ee6512..ee58d062 100644 --- a/docs/test-results/gimin-#332-async-pipeline-transition-metrics.md +++ b/docs/test-results/gimin-#332-async-pipeline-transition-metrics.md @@ -7,7 +7,7 @@ ```bash DB_PORT=55433 \ JWT_SECRET=docgrid-observability-test-secret-key-2026-with-at-least-32-bytes \ -./gradlew test +./backend/gradlew -p backend test ``` 결과: **1164 tests, 실패 0, errors 0, skipped 0** diff --git a/monitoring/prometheus/tests/docgrid-pipeline-alerts.test.yml b/monitoring/prometheus/tests/docgrid-pipeline-alerts.test.yml index e32bc93a..437554b2 100644 --- a/monitoring/prometheus/tests/docgrid-pipeline-alerts.test.yml +++ b/monitoring/prometheus/tests/docgrid-pipeline-alerts.test.yml @@ -50,6 +50,9 @@ tests: - series: 'docgrid_rag_job_completions_total{instance="backend-c:8081",environment="timeout",cluster="docgrid-timeout",outcome="timeout_swept"}' values: '0+1x8' alert_rule_test: + - eval_time: 3m + alertname: DocGridRagProviderFallbackSpike + exp_alerts: [] - eval_time: 4m alertname: DocGridRagProviderFallbackSpike exp_alerts: @@ -63,6 +66,9 @@ tests: exp_annotations: summary: DocGrid RAG provider fallback is increasing description: At least three RAG jobs used the provider fallback during the last ten minutes. + - eval_time: 3m + alertname: DocGridRagTimeoutSweepSpike + exp_alerts: [] - eval_time: 4m alertname: DocGridRagTimeoutSweepSpike exp_alerts: