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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions docs/reliability/transactional-outbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Kafka나 RabbitMQ가 필요한 구조는 아닙니다. MVP는 기존 PostgreSQL
| --- | --- |
| `event_publication` | 처리해야 할 이벤트와 현재 상태, 시도 횟수, 다음 시각, lease를 저장 |
| `event_consumption` | 어느 handler가 어느 이벤트를 이미 성공했는지 저장 |
| `outbox_manual_retry` | ADMIN의 수동 재처리 사유와 중복 방지 키 해시를 변경 불가 기록으로 저장 |

`event_publication`의 상태는 다음과 같습니다.

Expand Down Expand Up @@ -128,8 +129,36 @@ ORDER BY occurred_at;

`PROCESSING` lease가 만료되면 다음 poll에서 자동 복구됩니다. `RETRY_WAIT`도
`next_attempt_at` 이후 자동 처리됩니다. `REVIEW_REQUIRED`는 원인을 수정했다고 해서
DB를 임의로 `PENDING`으로 바꾸지 않습니다. 별도 관리 command와 감사로그가 구현되기
전에는 담당 개발자가 원인을 확인하고 forward migration 또는 후속 Issue로 복구합니다.
DB를 임의로 `PENDING`으로 바꾸지 않습니다.

### REVIEW_REQUIRED 수동 재처리

1. 안전한 `last_error_code`와 관련 handler 상태를 확인하고 원인을 먼저 해결합니다.
2. ADMIN Access Token으로 아래 API를 호출합니다.
3. 응답이 `202`이면 이벤트는 `PENDING`이 되고 다음 Outbox poll에서 한 번 더 시도됩니다.
4. `event_publication`, `outbox_manual_retry`, `audit_event`를 payload 없이 확인합니다.

```http
POST /api/v1/admin/outbox-events/{eventId}/retry
Authorization: Bearer {admin-access-token}
Idempotency-Key: incident-20260806-event-001
Content-Type: application/json

{
"expected_version": 3,
"reason": "내부 handler 복구와 점검을 완료했습니다."
}
```

- `expected_version`은 조회 당시 `event_publication.version`입니다. 그 사이 상태가 바뀌면
`409`로 거부하므로 최신 상태를 다시 확인합니다.
- 같은 `Idempotency-Key`와 같은 요청은 재실행하지 않고 최초 접수 결과를 반환합니다.
- 사유는 10~300자로 작성하며 이름·연락처·문서 원문·token·payload·예외 원문을
입력하지 않습니다.
- 수동 재처리가 승인되면 `attempt_count`를 0으로 초기화해 handler가 실제로 다시
실행될 기회를 부여합니다. 초기화 전 횟수는 `outbox_manual_retry.previous_attempt_count`에
변경 불가 이력으로 보존합니다. 이후 실패하면 일반 자동 재시도 한도를 다시 적용합니다.
- HR·VIEWER와 다른 사업장의 ADMIN은 호출할 수 없습니다.

`OUTBOX_ENABLED=false`는 자동 처리를 멈출 뿐 새 이벤트 저장을 막지 않습니다. 장애 중
이벤트가 계속 누적될 수 있으므로 backlog를 함께 관찰하고, 수정 배포 후 다시 활성화해
Expand All @@ -143,6 +172,8 @@ DB를 임의로 `PENDING`으로 바꾸지 않습니다. 별도 관리 command와
constraint, index
- 기능 통합 테스트: 실제 command가 올바른 event type과 최소 payload를 발행하는지
검증
- 운영 API 통합 테스트: ADMIN 권한, 사업장 격리, version 충돌, Idempotency-Key,
동시 재처리와 감사로그, 최대 횟수 이벤트의 실제 handler 재실행 검증

로컬 전체 검증:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public enum AuditAction {
AI_RUN_CREATED,
AI_RUN_ANSWERS_SUBMITTED,
AI_RUN_CANDIDATES_DECIDED,
OUTBOX_MANUAL_RETRY_REQUESTED,
WORKER_LINK_RESPONSE_SUBMITTED,
WORKER_LINK_ACCESSED,
USER_AGREEMENTS_RECORDED,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ public enum AuditTargetType {
WORKER_DOCUMENT,
DOCUMENT_REQUEST_DRAFT,
AI_RUN,
OUTBOX_EVENT,
USER_ACCOUNT
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.fowoco.server.reliability.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public record OutboxManualRetryRequest(
@JsonProperty("expected_version")
@Schema(description = "운영자가 확인한 현재 Outbox event version", example = "3")
@NotNull(message = "expected_version은 필수입니다.")
@Min(value = 0, message = "expected_version은 0 이상이어야 합니다.")
Long expectedVersion,

@Schema(
description = "재처리 근거. 개인정보·payload·token·예외 원문은 입력하지 않습니다.",
example = "일시 중단된 내부 handler 복구를 확인함"
)
@NotBlank(message = "재처리 사유를 입력해 주세요.")
@Size(min = 10, max = 300, message = "재처리 사유는 10자 이상 300자 이하로 입력해 주세요.")
String reason
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.fowoco.server.reliability.api;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fowoco.server.reliability.application.OutboxManualRetryResult;
import com.fowoco.server.reliability.domain.EventPublicationStatus;
import java.time.Instant;
import java.util.UUID;

public record OutboxManualRetryResponse(
@JsonProperty("event_id") UUID eventId,
@JsonProperty("accepted_status") EventPublicationStatus acceptedStatus,
@JsonProperty("accepted_version") long acceptedVersion,
@JsonProperty("accepted_at") Instant acceptedAt,
@JsonProperty("already_requested") boolean alreadyRequested
) {
static OutboxManualRetryResponse from(OutboxManualRetryResult result) {
return new OutboxManualRetryResponse(
result.eventId(),
result.acceptedStatus(),
result.acceptedVersion(),
result.acceptedAt(),
result.alreadyRequested()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.fowoco.server.reliability.api;

import com.fowoco.server.auth.application.port.ActorContextProvider;
import com.fowoco.server.common.web.RequestMetadata;
import com.fowoco.server.reliability.application.OutboxManualRetryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.UUID;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "Operations", description = "ADMIN 전용 장애 복구 작업")
@SecurityRequirement(name = "bearerAuth")
@RestController
@RequestMapping("/api/v1/admin/outbox-events")
public class OutboxOperationsController {

private final OutboxManualRetryService retryService;
private final ActorContextProvider actorContextProvider;

public OutboxOperationsController(
OutboxManualRetryService retryService,
ActorContextProvider actorContextProvider
) {
this.retryService = retryService;
this.actorContextProvider = actorContextProvider;
}

@Operation(
operationId = "retryOutboxEvent",
summary = "REVIEW_REQUIRED Outbox 이벤트 재처리",
description = "ADMIN이 원인을 해결한 뒤 멈춘 이벤트를 PENDING으로 되돌립니다. "
+ "payload·예외 원문은 반환하지 않으며 실제 처리는 Outbox worker가 수행합니다."
)
@ApiResponses({
@ApiResponse(responseCode = "202", description = "재처리 요청 접수"),
@ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"),
@ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"),
@ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"),
@ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"),
@ApiResponse(responseCode = "409", ref = "#/components/responses/Conflict")
})
@PreAuthorize("hasRole('ADMIN')")
@PostMapping(
path = "/{eventId}/retry",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<OutboxManualRetryResponse> retry(
@Parameter(description = "재처리할 Outbox event ID")
@PathVariable UUID eventId,
@Parameter(description = "같은 운영 요청의 중복 실행을 막는 키", required = true)
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody OutboxManualRetryRequest request,
HttpServletRequest servletRequest
) {
return ResponseEntity.accepted().body(OutboxManualRetryResponse.from(
retryService.requestRetry(
eventId,
request.expectedVersion(),
request.reason(),
idempotencyKey,
actorContextProvider.requireCurrentActor(),
RequestMetadata.from(servletRequest)
)
));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.fowoco.server.reliability.application;

import com.fowoco.server.reliability.domain.EventPublicationStatus;
import java.time.Instant;
import java.util.UUID;

public record OutboxManualRetryResult(
UUID eventId,
EventPublicationStatus acceptedStatus,
long acceptedVersion,
Instant acceptedAt,
boolean alreadyRequested
) {
}
Loading
Loading