diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 0f1c159..ca92665 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -26,8 +26,12 @@ - tb_ship_main_info, core20 테이블 mmsi 컬럼 업데이트 추가 (#28) - 자동 재수집 및 재수집 프로세스 전면 개선 (#30) - 배치 작업 목록 UX 개선: 상태 필터, 카드/테이블 뷰, 정렬, 실행 중 강조 (#33) +- 재시도 초과 레코드 초기화 API/UI 추가 ### 수정 +- 자동 재수집 JobParameter 오버플로우 수정 (VARCHAR 2500 제한 해결) +- retryCount 세마틱 오류 수정 (0부터 시작, 재수집 실패 시 증가) +- 실패 레코드 저장 타이밍 경합 해결 (동기 저장으로 변경) - ChnPrmShip 캐시 갱신 조건 완화 및 스케줄 이전 실행 시간 표시 (#3) - 재수집 관리 및 이력 추가 (#4) - 재수집 중복 실행 문제 해결 (#9) @@ -37,6 +41,8 @@ - 타임라인 상세 화면 이동 오류 수정 및 실행 중 작업 상세 버튼 추가 (#34) ### 변경 +- 실패 레코드 Upsert 패턴 적용 (동일 키 중복 방지) +- 재시도 상태 배지 표시 (대기/재시도 N/3/재시도 초과) - 미사용 Dead Code 정리 (~1,200 LOC 삭제) ### 기타 @@ -46,3 +52,4 @@ - CLAUDE_BOT_TOKEN 갱신 (#26) - 팀 글로벌 워크플로우 1.5.0 동기화 - 팀 워크플로우 v1.6.1 동기화 +- 실행 확인 모달 시작/종료일시 항목 제거 diff --git a/docs/recollection-process.md b/docs/recollection-process.md index e9eb124..384ee6d 100644 --- a/docs/recollection-process.md +++ b/docs/recollection-process.md @@ -8,10 +8,9 @@ | **대상** | 실패한 record key | 실패한 record key | 날짜 범위 전체 | | `executionMode` | RECOLLECT | RECOLLECT | RECOLLECT | | `executor` | AUTO_RETRY | MANUAL_RETRY | MANUAL | -| `retryRecordKeys` | 있음 (콤마 구분) | 있음 (콤마 구분) | 없음 | | `sourceStepExecutionId` | 있음 | 있음 | 없음 | | `apiKey` | 있음 (ExecutionContext) | 없음 (resolveApiKey fallback) | 있음 (파라미터) | -| **Reader 모드** | retry (키 기반) | retry (키 기반) | normal (API 전체 호출) | +| **Reader 모드** | retry (DB에서 키 조회) | retry (DB에서 키 조회) | normal (API 전체 호출) | | **last_success_date** | Tasklet 스킵 → 불변 | Tasklet 스킵 → 불변 | Tasklet 실행 → 갱신 → 원복 | | **이력 날짜범위** | NULL / NULL | NULL / NULL | from / to | | **중복 검사** | 안함 | 안함 | 함 (findOverlappingHistories) | @@ -29,7 +28,9 @@ │ └─ failedImoNumbers에 누적 │ ├─ Reader.afterFetch(null) [일반 모드] - │ ├─ BatchFailedRecordService.saveFailedRecords() ──→ [batch_failed_record] INSERT (FAILED) + │ ├─ BatchFailedRecordService.saveFailedRecordsSync() [동기 저장] + │ │ ├─ 기존 FAILED 레코드 존재 → UPDATE (실행정보 갱신, retryCount 유지) + │ │ └─ 신규 키 → INSERT (retryCount=0, status=FAILED) │ └─ finally: ExecutionContext에 failedRecordKeys/stepId/apiKey 저장 │ ├─ [Job COMPLETED] @@ -39,7 +40,8 @@ │ ├─ status == COMPLETED ✓ │ ├─ 모든 Step의 failedRecordKeys → LinkedHashSet 병합 │ ├─ findExceededRetryKeys() → retryCount >= 3 인 키 제외 - │ └─ AutoRetryTriggerService.triggerRetryAsync() [autoRetryExecutor 스레드] + │ └─ AutoRetryTriggerService.triggerRetryAsync(jobName, failedCount, stepId, apiKey) + │ └─ JobParameter에 sourceStepExecutionId만 전달 (키 목록은 DB에서 조회) │ ├─ [재수집 Job 시작 (executionMode=RECOLLECT, executor=AUTO_RETRY)] │ @@ -47,14 +49,20 @@ │ ├─ last_success_date 저장 (ExecutionContext) │ └─ recordStart() ──→ [batch_recollection_history] INSERT (STARTED, 날짜 NULL) │ + ├─ ShipDetailUpdateJobConfig.shipDetailUpdateDataReader() [@StepScope 빈 초기화] + │ └─ BatchFailedRecordRepository.findFailedRecordKeysByStepExecutionId() → DB에서 실패 키 조회 + │ ├─ Reader.beforeFetch() [retry 모드] │ └─ allImoNumbers = retryRecordKeys (API 호출 없이 키 목록 직접 사용) │ ├─ Reader.afterFetch(null) [retry 모드] │ ├─ resolveSuccessfulRetries() ──→ [batch_failed_record] UPDATE (RESOLVED) - │ └─ 재실패 건 saveFailedRecords() ──→ [batch_failed_record] INSERT (FAILED, 새 레코드) + │ │ └─ sourceStepExecutionId 기준 (아직 원본 stepId 보유, resolve 먼저 실행) + │ └─ 재실패 건 saveFailedRecords() [비동기] + │ ├─ 기존 FAILED 레코드 → UPDATE (실행정보 갱신 + retryCount + 1) + │ └─ 신규 키 → INSERT (retryCount=1) │ - ├─ retryModeDecider → "RETRY" → lastExecutionUpdateStep 스킵 + ├─ retryModeDecider → executionMode="RECOLLECT" → "RETRY" → lastExecutionUpdateStep 스킵 │ ├─ RecollectionJobExecutionListener.afterJob() │ ├─ recordCompletion() ──→ [batch_recollection_history] UPDATE (COMPLETED/FAILED) @@ -73,8 +81,7 @@ │ ├─ batchApi.retryFailedRecords(jobName, recordKeys, stepExecutionId) │ └─ POST /api/batch/jobs/{jobName}/execute - │ ?retryRecordKeys=IMO1,IMO2 - │ &sourceStepExecutionId=123 + │ ?sourceStepExecutionId=123 │ &executionMode=RECOLLECT │ &executor=MANUAL_RETRY │ &reason=실패 건 수동 재수집 (N건) @@ -84,8 +91,9 @@ │ ├─ [이후 흐름은 AUTO_RETRY와 동일] │ ├─ RecollectionJobExecutionListener.beforeJob() → 이력 INSERT + │ ├─ Reader 빈 초기화 시 DB에서 실패 키 조회 │ ├─ Reader [retry 모드] → 키 기반 재처리 - │ ├─ retryModeDecider → "RETRY" → Tasklet 스킵 + │ ├─ retryModeDecider → executionMode="RECOLLECT" → "RETRY" → Tasklet 스킵 │ └─ RecollectionJobExecutionListener.afterJob() → 이력 UPDATE + 복원 │ └─ 성공 시 UI에서 새 실행 상세 화면으로 이동 @@ -124,20 +132,96 @@ ## 3. 테이블 영향 매트릭스 -| 테이블 | AUTO_RETRY | MANUAL_RETRY | MANUAL(날짜) | UI 일괄 RESOLVED | -|--------|-----------|--------------|-------------|-----------------| -| `batch_failed_record` | INSERT(FAILED) + UPDATE(RESOLVED) | INSERT(FAILED) + UPDATE(RESOLVED) | - | UPDATE(RESOLVED) | -| `batch_recollection_history` | INSERT + UPDATE | INSERT + UPDATE | INSERT + UPDATE | - | -| `batch_collection_period` | READ (apiKeyName 조회) | READ (apiKeyName 조회) | READ/WRITE (날짜범위) | - | -| `batch_last_execution` | READ (복원용) | READ (복원용) | READ → WRITE(갱신) → WRITE(복원) | - | -| `batch_job_execution` | INSERT (Spring Batch 자동) | INSERT (Spring Batch 자동) | INSERT (Spring Batch 자동) | - | +| 테이블 | AUTO_RETRY | MANUAL_RETRY | MANUAL(날짜) | UI 일괄 RESOLVED | UI 재시도 초기화 | +|--------|-----------|--------------|-------------|-----------------|-----------------| +| `batch_failed_record` | UPSERT(FAILED) + UPDATE(RESOLVED) | UPSERT(FAILED) + UPDATE(RESOLVED) | - | UPDATE(RESOLVED) | UPDATE(retryCount=0) | +| `batch_recollection_history` | INSERT + UPDATE | INSERT + UPDATE | INSERT + UPDATE | - | - | +| `batch_collection_period` | READ (apiKeyName 조회) | READ (apiKeyName 조회) | READ/WRITE (날짜범위) | - | - | +| `batch_last_execution` | READ (복원용) | READ (복원용) | READ → WRITE(갱신) → WRITE(복원) | - | - | +| `batch_job_execution` | INSERT (Spring Batch 자동) | INSERT (Spring Batch 자동) | INSERT (Spring Batch 자동) | - | - | --- -## 4. 무한루프 방지 구조 +## 4. 실패 레코드 관리 + +### retryCount 세마틱 + +`retryCount`는 해당 레코드가 재수집된 횟수를 나타냅니다. + +| 시점 | retryCount | 의미 | +|------|-----------|------| +| 최초 실패 | 0 | 아직 재시도하지 않음 | +| 1차 재수집 실패 | 1 | 1번 재시도했으나 재실패 | +| 2차 재수집 실패 | 2 | 2번 재시도했으나 재실패 | +| 3차 재수집 실패 | 3 | MAX_AUTO_RETRY_COUNT 도달 → 자동 재수집 제외 | + +### 중복 방지 (Upsert 패턴) + +동일 `(jobName, recordKey)`에 대해 FAILED 레코드는 **항상 1건만 유지**됩니다. ``` -[이중 가드] +[일반 모드 저장 시] + ├─ 기존 FAILED 레코드 없음 → INSERT (retryCount=0) + └─ 기존 FAILED 레코드 있음 → UPDATE (jobExecutionId, stepExecutionId, errorMessage 갱신, retryCount 유지) + +[재수집 모드 저장 시 (재실패)] + ├─ 기존 FAILED 레코드 없음 → INSERT (retryCount=1) + └─ 기존 FAILED 레코드 있음 → UPDATE (jobExecutionId, stepExecutionId, errorMessage 갱신, retryCount + 1) +``` + +중복 방지 효과: +- 배치 실행 1 → IMO 1234 실패 → INSERT (stepExecId=100, retryCount=0) +- 배치 실행 2 → IMO 1234 또 실패 → UPDATE (stepExecId=200, retryCount=0 유지) +- 재수집 트리거 → stepExecId=200 기준 조회 → 1건만 재수집 (중복 재수집 방지) + +### 동기/비동기 저장 구분 + +| 호출 시점 | 메서드 | 이유 | +|-----------|--------|------| +| 일반 모드 afterFetch | `saveFailedRecordsSync` (동기) | RECOLLECT Job의 Reader가 DB 조회 시 커밋 완료 보장 | +| 재수집 모드 afterFetch | `saveFailedRecords` (비동기) | 후속 RECOLLECT 없으므로 비동기 OK | + +### 재시도 초과 레코드 관리 + +retryCount >= MAX_AUTO_RETRY_COUNT(3)에 도달한 레코드는 자동 재수집에서 제외됩니다. +사용자는 UI에서 다음 3가지 방식으로 처리할 수 있습니다. + +| 액션 | 설명 | API | 효과 | +|------|------|-----|------| +| **재시도 초기화** | retryCount를 0으로 리셋 | `POST /api/batch/failed-records/reset-retry` | 다음 배치 실행 시 자동 재수집 대상에 다시 포함 | +| **수동 재수집** | 즉시 RECOLLECT Job 트리거 | `POST /api/batch/jobs/{jobName}/execute` | 선택한 키에 대해 재수집 즉시 실행 | +| **일괄 RESOLVED** | FAILED → RESOLVED 상태 변경 | `POST /api/batch/failed-records/resolve` | 해당 레코드 포기 (더 이상 재수집 안 함) | + +``` +[재시도 초과 레코드 처리 흐름] + + retryCount >= 3 도달 → 자동 재수집 제외 + │ + ├─ [재시도 초기화] → retryCount = 0 → 다음 배치 실행에서 자동 재수집 재개 + │ + ├─ [수동 재수집] → RECOLLECT Job 즉시 실행 → 성공 시 RESOLVED, 실패 시 retryCount + 1 + │ + └─ [일괄 RESOLVED] → status = RESOLVED → 재수집 대상에서 영구 제외 +``` + +### UI 재시도 상태 표시 + +FailedRecordsToggle 테이블의 "재시도" 컬럼에 retryCount 기반 상태 배지를 표시합니다. + +| retryCount | 배지 | 색상 | 의미 | +|-----------|------|------|------| +| 0 | `대기` | blue | 아직 재시도하지 않음, 자동 재수집 대상 | +| 1~2 | `재시도 N/3` | amber | 재수집 진행 중 | +| >= 3 | `재시도 초과` | red | 자동 재수집 제외, 사용자 조치 필요 | + +"재시도 초기화" 버튼은 `재시도 초과` 상태 레코드가 1건 이상 있을 때만 표시됩니다. + +--- + +## 5. 무한루프 방지 구조 + +``` +[삼중 가드] Guard 1: AutoRetryJobExecutionListener └─ executionMode == "RECOLLECT" → 즉시 리턴 (재수집 Job에서는 자동 재수집 트리거 안 함) @@ -146,47 +230,53 @@ Guard 2: findExceededRetryKeys() └─ retryCount >= 3 인 키 → mergedKeys에서 제거 └─ 남은 키 없으면 → 재수집 자체 스킵 +Guard 3: Reader afterFetch [retry 모드] + └─ ExecutionContext에 failedRecordKeys 미기록 → 리스너가 감지하지 못함 + [시나리오] - 일반 실행 → 실패 3건 → 자동 재수집(retryCount=3 저장) - → 재수집 Job은 executionMode=RECOLLECT → Guard 1에서 차단 + 일반 실행 → 실패 3건 → batch_failed_record INSERT (retryCount=0) + → 자동 재수집 1차 → 재실패 → retryCount=1 + → 자동 재수집 2차 → 재실패 → retryCount=2 + → 자동 재수집 3차 → 재실패 → retryCount=3 → 다음 정상 실행에서 같은 키 다시 실패해도 retryCount >= 3 → Guard 2에서 차단 ``` --- -## 5. 프론트엔드 UI 진입점 +## 6. 프론트엔드 UI 진입점 | 화면 | 컴포넌트/영역 | 기능 | |------|--------------|------| | **Executions** | failedRecordCount 뱃지 | COMPLETED 상태 + 미해결 실패건 > 0 → amber 뱃지 | -| **ExecutionDetail** | FailedRecordsToggle | 실패 레코드 목록 + "재수집 실행" + "일괄 RESOLVED" 버튼 | +| **ExecutionDetail** | FailedRecordsToggle | 실패 레코드 목록 + 재시도 상태 배지 + "재시도 초기화" / "재수집 실행" / "일괄 RESOLVED" 버튼 | | **RecollectDetail** | FailedRecordsToggle | 재수집 상세 내 실패 레코드 + 동일 버튼 | | **Recollects** | 이력 목록 + 수집기간 관리 | 검색/필터 + CSV 내보내기 + 재수집 실행 + 기간 편집 | | **Dashboard** | 재수집 현황 위젯 | 통계 카드 5개 + 최근 5건 테이블 | --- -## 6. 주요 파일 맵 +## 7. 주요 파일 맵 ### 백엔드 | 파일 | 역할 | |------|------| | `ShipDetailUpdateDataReader.java` | API 호출 + 실패 감지 + ExecutionContext 저장 | -| `ShipDetailUpdateJobConfig.java` | Job/Step 구성 + retryModeDecider + 리스너 등록 | +| `ShipDetailUpdateJobConfig.java` | Job/Step 구성 + retryModeDecider(executionMode 기반) + DB에서 실패 키 조회 | | `AutoRetryJobExecutionListener.java` | Job 완료 후 실패 키 병합 + retryCount 필터 + 트리거 | -| `AutoRetryTriggerService.java` | @Async 비동기 Job 재실행 (autoRetryExecutor) | +| `AutoRetryTriggerService.java` | @Async 비동기 Job 재실행 (sourceStepExecutionId만 전달) | | `RecollectionJobExecutionListener.java` | 재수집 이력 기록 + last_success_date 백업/복원 | | `RecollectionHistoryService.java` | 이력 CRUD + 통계 + 수집기간 관리 | -| `BatchFailedRecordService.java` | 실패 레코드 저장/RESOLVED 처리 | +| `BatchFailedRecordService.java` | 실패 레코드 Upsert(동기/비동기) + RESOLVED 처리 | +| `BatchFailedRecordRepository.java` | 실패 키 조회 + 벌크 UPDATE(retryCount 유지/증가/초기화) + RESOLVED | | `BatchService.java` | Job 실행 + failedRecordCount 조회 | -| `BatchController.java` | REST API 엔드포인트 (실행/이력/CSV/RESOLVED) | +| `BatchController.java` | REST API 엔드포인트 (실행/이력/CSV/RESOLVED/재시도 초기화) | ### 프론트엔드 | 파일 | 역할 | |------|------| -| `batchApi.ts` | API 클라이언트 (retryFailedRecords, resolveFailedRecords, exportCSV 등) | +| `batchApi.ts` | API 클라이언트 (retryFailedRecords, resolveFailedRecords, resetRetryCount, exportCSV 등) | | `Executions.tsx` | 실행 목록 + failedRecordCount amber 뱃지 | | `ExecutionDetail.tsx` | 실행 상세 + FailedRecordsToggle (재수집/RESOLVED) | | `RecollectDetail.tsx` | 재수집 상세 + FailedRecordsToggle (재수집/RESOLVED) | diff --git a/frontend/src/api/batchApi.ts b/frontend/src/api/batchApi.ts index cfc95c1..9ccb570 100644 --- a/frontend/src/api/batchApi.ts +++ b/frontend/src/api/batchApi.ts @@ -498,6 +498,10 @@ export const batchApi = { postJson<{ success: boolean; message: string; resolvedCount?: number }>( `${BASE}/failed-records/resolve`, { ids }), + resetRetryCount: (ids: number[]) => + postJson<{ success: boolean; message: string; resetCount?: number }>( + `${BASE}/failed-records/reset-retry`, { ids }), + exportRecollectionHistories: (params: { apiKey?: string; jobName?: string; diff --git a/frontend/src/pages/ExecutionDetail.tsx b/frontend/src/pages/ExecutionDetail.tsx index 3ca4255..33cf33c 100644 --- a/frontend/src/pages/ExecutionDetail.tsx +++ b/frontend/src/pages/ExecutionDetail.tsx @@ -353,6 +353,8 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F const [showResolveConfirm, setShowResolveConfirm] = useState(false); const [retrying, setRetrying] = useState(false); const [resolving, setResolving] = useState(false); + const [showResetConfirm, setShowResetConfirm] = useState(false); + const [resetting, setResetting] = useState(false); const [page, setPage] = useState(0); const navigate = useNavigate(); @@ -368,6 +370,17 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F } }; + const MAX_RETRY_COUNT = 3; + + const retryStatusLabel = (record: FailedRecordDto) => { + if (record.status !== 'FAILED') return null; + if (record.retryCount >= MAX_RETRY_COUNT) return { label: '재시도 초과', color: 'text-red-600 bg-red-100' }; + if (record.retryCount > 0) return { label: `재시도 ${record.retryCount}/${MAX_RETRY_COUNT}`, color: 'text-amber-600 bg-amber-100' }; + return { label: '대기', color: 'text-blue-600 bg-blue-100' }; + }; + + const exceededRecords = failedRecords.filter((r) => r.retryCount >= MAX_RETRY_COUNT); + const handleRetry = async () => { setRetrying(true); try { @@ -404,6 +417,20 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F } }; + const handleResetRetry = async () => { + setResetting(true); + try { + const ids = exceededRecords.map((r) => r.id); + await batchApi.resetRetryCount(ids); + setShowResetConfirm(false); + navigate(0); + } catch { + alert('재시도 초기화에 실패했습니다.'); + } finally { + setResetting(false); + } + }; + return (