Merge pull request 'fix(batch): 자동 재수집 파라미터 오버플로우 수정 및 실패 레코드 관리 개선' (#38) from fix/auto-retry-parameter-overflow into develop
This commit is contained in:
커밋
9a211433ad
@ -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 동기화
|
||||
- 실행 확인 모달 시작/종료일시 항목 제거
|
||||
|
||||
@ -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) |
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 (
|
||||
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@ -422,6 +449,17 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
||||
|
||||
{failedRecords.length > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{exceededRecords.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowResetConfirm(true)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 border border-amber-200 rounded-md transition-colors"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
재시도 초기화 ({exceededRecords.length}건)
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowResolveConfirm(true)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 rounded-md transition-colors"
|
||||
@ -469,8 +507,17 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
||||
<td className="px-2 py-1.5 text-red-600 max-w-[200px] truncate" title={record.errorMessage || ''}>
|
||||
{record.errorMessage || '-'}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center text-red-900">
|
||||
{record.retryCount}
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
{(() => {
|
||||
const info = retryStatusLabel(record);
|
||||
return info ? (
|
||||
<span className={`inline-flex px-1.5 py-0.5 text-[10px] font-medium rounded-full ${info.color}`}>
|
||||
{info.label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-wing-muted">-</span>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<span className={`inline-flex px-1.5 py-0.5 text-[10px] font-medium rounded-full ${statusColor(record.status)}`}>
|
||||
@ -581,6 +628,44 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 재시도 초기화 확인 다이얼로그 */}
|
||||
{showResetConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
|
||||
<h3 className="text-lg font-semibold text-wing-text mb-2">
|
||||
재시도 초기화 확인
|
||||
</h3>
|
||||
<p className="text-sm text-wing-muted mb-4">
|
||||
재시도 횟수를 초과한 {exceededRecords.length}건의 retryCount를 0으로 초기화합니다.
|
||||
초기화 후 다음 배치 실행 시 자동 재수집 대상에 포함됩니다.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowResetConfirm(false)}
|
||||
disabled={resetting}
|
||||
className="px-4 py-2 text-sm font-medium text-wing-muted bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetRetry}
|
||||
disabled={resetting}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-amber-500 hover:bg-amber-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
|
||||
>
|
||||
{resetting ? (
|
||||
<>
|
||||
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
처리 중...
|
||||
</>
|
||||
) : (
|
||||
'초기화 실행'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -60,8 +60,6 @@ export default function Jobs() {
|
||||
const [executeModalOpen, setExecuteModalOpen] = useState(false);
|
||||
const [targetJob, setTargetJob] = useState('');
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [stopDate, setStopDate] = useState('');
|
||||
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
@ -121,8 +119,6 @@ export default function Jobs() {
|
||||
|
||||
const handleExecuteClick = (jobName: string) => {
|
||||
setTargetJob(jobName);
|
||||
setStartDate('');
|
||||
setStopDate('');
|
||||
setExecuteModalOpen(true);
|
||||
};
|
||||
|
||||
@ -130,14 +126,7 @@ export default function Jobs() {
|
||||
if (!targetJob) return;
|
||||
setExecuting(true);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (startDate) params.startDate = startDate;
|
||||
if (stopDate) params.stopDate = stopDate;
|
||||
|
||||
const result = await batchApi.executeJob(
|
||||
targetJob,
|
||||
Object.keys(params).length > 0 ? params : undefined,
|
||||
);
|
||||
const result = await batchApi.executeJob(targetJob);
|
||||
showToast(
|
||||
result.message || `${targetJob} 실행 요청 완료`,
|
||||
'success',
|
||||
@ -503,40 +492,7 @@ export default function Jobs() {
|
||||
"{targetJob}" 작업을 실행하시겠습니까?
|
||||
</p>
|
||||
|
||||
{/* Date parameters */}
|
||||
<div className="space-y-3 mb-6">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-wing-text mb-1">
|
||||
시작일시 (선택)
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
|
||||
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-wing-text mb-1">
|
||||
종료일시 (선택)
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={stopDate}
|
||||
onChange={(e) => setStopDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
|
||||
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
{(startDate || stopDate) && (
|
||||
<p className="text-xs text-wing-muted">
|
||||
날짜를 지정하면 해당 범위의 데이터를 수집합니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => setExecuteModalOpen(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg
|
||||
|
||||
@ -404,6 +404,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();
|
||||
|
||||
@ -419,6 +421,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 {
|
||||
@ -455,6 +468,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 (
|
||||
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@ -473,6 +500,17 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
||||
|
||||
{failedRecords.length > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{exceededRecords.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowResetConfirm(true)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 border border-amber-200 rounded-md transition-colors"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
재시도 초기화 ({exceededRecords.length}건)
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowResolveConfirm(true)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 rounded-md transition-colors"
|
||||
@ -520,8 +558,17 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
||||
<td className="px-2 py-1.5 text-red-600 max-w-[200px] truncate" title={record.errorMessage || ''}>
|
||||
{record.errorMessage || '-'}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center text-red-900">
|
||||
{record.retryCount}
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
{(() => {
|
||||
const info = retryStatusLabel(record);
|
||||
return info ? (
|
||||
<span className={`inline-flex px-1.5 py-0.5 text-[10px] font-medium rounded-full ${info.color}`}>
|
||||
{info.label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-wing-muted">-</span>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<span className={`inline-flex px-1.5 py-0.5 text-[10px] font-medium rounded-full ${statusColor(record.status)}`}>
|
||||
@ -632,6 +679,44 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 재시도 초기화 확인 다이얼로그 */}
|
||||
{showResetConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
|
||||
<h3 className="text-lg font-semibold text-wing-text mb-2">
|
||||
재시도 초기화 확인
|
||||
</h3>
|
||||
<p className="text-sm text-wing-muted mb-4">
|
||||
재시도 횟수를 초과한 {exceededRecords.length}건의 retryCount를 0으로 초기화합니다.
|
||||
초기화 후 다음 배치 실행 시 자동 재수집 대상에 포함됩니다.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowResetConfirm(false)}
|
||||
disabled={resetting}
|
||||
className="px-4 py-2 text-sm font-medium text-wing-muted bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetRetry}
|
||||
disabled={resetting}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-amber-500 hover:bg-amber-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
|
||||
>
|
||||
{resetting ? (
|
||||
<>
|
||||
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
처리 중...
|
||||
</>
|
||||
) : (
|
||||
'초기화 실행'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -109,12 +109,11 @@ public class AutoRetryJobExecutionListener implements JobExecutionListener {
|
||||
return;
|
||||
}
|
||||
|
||||
String mergedFailedKeys = String.join(",", mergedKeys);
|
||||
log.info("[AutoRetry] {} Job 완료 후 실패 건 {}건 감지 → 자동 재수집 트리거",
|
||||
jobName, mergedKeys.size());
|
||||
|
||||
// 합산된 키로 1회만 triggerRetryAsync 호출
|
||||
// sourceStepExecutionId 기반으로 1회만 triggerRetryAsync 호출 (실패 키는 DB에서 직접 조회)
|
||||
autoRetryTriggerService.triggerRetryAsync(
|
||||
jobName, mergedFailedKeys, sourceStepExecutionId, apiKey);
|
||||
jobName, mergedKeys.size(), sourceStepExecutionId, apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,7 +30,7 @@ public class AutoRetryTriggerService {
|
||||
}
|
||||
|
||||
@Async("autoRetryExecutor")
|
||||
public void triggerRetryAsync(String jobName, String failedKeys,
|
||||
public void triggerRetryAsync(String jobName, int failedCount,
|
||||
Long sourceStepExecutionId, String apiKey) {
|
||||
try {
|
||||
Job job = jobMap.get(jobName);
|
||||
@ -41,7 +41,6 @@ public class AutoRetryTriggerService {
|
||||
|
||||
JobParametersBuilder builder = new JobParametersBuilder()
|
||||
.addLong("timestamp", System.currentTimeMillis())
|
||||
.addString("retryRecordKeys", failedKeys)
|
||||
.addString("sourceStepExecutionId", String.valueOf(sourceStepExecutionId))
|
||||
.addString("executionMode", "RECOLLECT")
|
||||
.addString("reason", "자동 재수집 (실패 건 자동 처리)")
|
||||
@ -54,7 +53,7 @@ public class AutoRetryTriggerService {
|
||||
JobParameters retryParams = builder.toJobParameters();
|
||||
|
||||
log.info("[AutoRetry] 재수집 Job 실행 시작: jobName={}, 실패건={}, sourceStepExecutionId={}",
|
||||
jobName, failedKeys.split(",").length, sourceStepExecutionId);
|
||||
jobName, failedCount, sourceStepExecutionId);
|
||||
|
||||
JobExecution retryExecution = jobLauncher.run(job, retryParams);
|
||||
|
||||
|
||||
@ -648,6 +648,33 @@ public class BatchController {
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "실패 레코드 재시도 횟수 초기화", description = "재시도 횟수를 초과한 FAILED 레코드의 retryCount를 0으로 초기화하여 자동 재수집 대상으로 복원합니다")
|
||||
@PostMapping("/failed-records/reset-retry")
|
||||
public ResponseEntity<Map<String, Object>> resetRetryCount(
|
||||
@RequestBody Map<String, Object> request) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Integer> rawIds = (List<Integer>) request.get("ids");
|
||||
if (rawIds == null || rawIds.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"success", false,
|
||||
"message", "ids는 필수이며 비어있을 수 없습니다"));
|
||||
}
|
||||
List<Long> ids = rawIds.stream().map(Integer::longValue).toList();
|
||||
log.info("Reset retry count: ids count={}", ids.size());
|
||||
try {
|
||||
int reset = batchFailedRecordService.resetRetryCount(ids);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"resetCount", reset,
|
||||
"message", reset + "건의 실패 레코드 재시도 횟수가 초기화되었습니다"));
|
||||
} catch (Exception e) {
|
||||
log.error("Error resetting retry count", e);
|
||||
return ResponseEntity.internalServerError().body(Map.of(
|
||||
"success", false,
|
||||
"message", "재시도 횟수 초기화 실패: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 재수집 이력 CSV 내보내기 API ──────────────────────────────
|
||||
|
||||
@Operation(summary = "재수집 이력 CSV 내보내기", description = "필터 조건으로 재수집 이력을 CSV 파일로 내보냅니다 (최대 10,000건)")
|
||||
|
||||
@ -77,4 +77,60 @@ public interface BatchFailedRecordRepository extends JpaRepository<BatchFailedRe
|
||||
List<String> findExceededRetryKeys(@Param("jobName") String jobName,
|
||||
@Param("recordKeys") List<String> recordKeys,
|
||||
@Param("maxRetryCount") int maxRetryCount);
|
||||
|
||||
/**
|
||||
* 특정 Step 실행의 미해결(FAILED) 실패 레코드 키 목록 조회.
|
||||
* 자동 재수집 시 JobParameter 대신 DB에서 직접 조회하여 VARCHAR(2500) 제한을 우회.
|
||||
*/
|
||||
@Query("SELECT r.recordKey FROM BatchFailedRecord r " +
|
||||
"WHERE r.jobName = :jobName AND r.stepExecutionId = :stepExecutionId " +
|
||||
"AND r.status = 'FAILED'")
|
||||
List<String> findFailedRecordKeysByStepExecutionId(
|
||||
@Param("jobName") String jobName,
|
||||
@Param("stepExecutionId") Long stepExecutionId);
|
||||
|
||||
/**
|
||||
* 동일 Job에서 이미 FAILED 상태인 recordKey 목록 조회 (중복 방지용)
|
||||
*/
|
||||
@Query("SELECT r.recordKey FROM BatchFailedRecord r " +
|
||||
"WHERE r.jobName = :jobName AND r.recordKey IN :recordKeys AND r.status = 'FAILED'")
|
||||
List<String> findExistingFailedKeys(@Param("jobName") String jobName,
|
||||
@Param("recordKeys") List<String> recordKeys);
|
||||
|
||||
/**
|
||||
* 기존 FAILED 레코드의 실행 정보를 벌크 업데이트 (retryCount 유지).
|
||||
* 동일 키가 재실패할 때 최신 실행 정보로 갱신.
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE BatchFailedRecord r SET r.jobExecutionId = :jobExecutionId, " +
|
||||
"r.stepExecutionId = :stepExecutionId, r.errorMessage = :errorMessage " +
|
||||
"WHERE r.jobName = :jobName AND r.recordKey IN :recordKeys AND r.status = 'FAILED'")
|
||||
int updateFailedRecordExecutions(@Param("jobName") String jobName,
|
||||
@Param("recordKeys") List<String> recordKeys,
|
||||
@Param("jobExecutionId") Long jobExecutionId,
|
||||
@Param("stepExecutionId") Long stepExecutionId,
|
||||
@Param("errorMessage") String errorMessage);
|
||||
|
||||
/**
|
||||
* 기존 FAILED 레코드의 실행 정보를 벌크 업데이트하면서 retryCount를 1 증가.
|
||||
* 재수집(RECOLLECT) 모드에서 재실패한 레코드에 사용.
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE BatchFailedRecord r SET r.jobExecutionId = :jobExecutionId, " +
|
||||
"r.stepExecutionId = :stepExecutionId, r.errorMessage = :errorMessage, " +
|
||||
"r.retryCount = r.retryCount + 1 " +
|
||||
"WHERE r.jobName = :jobName AND r.recordKey IN :recordKeys AND r.status = 'FAILED'")
|
||||
int incrementRetryAndUpdateFailedRecords(@Param("jobName") String jobName,
|
||||
@Param("recordKeys") List<String> recordKeys,
|
||||
@Param("jobExecutionId") Long jobExecutionId,
|
||||
@Param("stepExecutionId") Long stepExecutionId,
|
||||
@Param("errorMessage") String errorMessage);
|
||||
|
||||
/**
|
||||
* FAILED 상태 레코드의 retryCount를 0으로 초기화 (재시도 초과 건 재활성화)
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE BatchFailedRecord r SET r.retryCount = 0 " +
|
||||
"WHERE r.id IN :ids AND r.status = 'FAILED'")
|
||||
int resetRetryCount(@Param("ids") List<Long> ids);
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import com.snp.batch.jobs.shipdetail.batch.entity.ShipDetailEntity;
|
||||
import com.snp.batch.jobs.shipdetail.batch.processor.ShipDetailDataProcessor;
|
||||
import com.snp.batch.jobs.shipdetail.batch.reader.ShipDetailUpdateDataReader;
|
||||
import com.snp.batch.jobs.shipdetail.batch.writer.ShipDetailDataWriter;
|
||||
import com.snp.batch.global.repository.BatchFailedRecordRepository;
|
||||
import com.snp.batch.service.BatchApiLogService;
|
||||
import com.snp.batch.service.BatchDateService;
|
||||
import com.snp.batch.service.BatchFailedRecordService;
|
||||
@ -37,7 +38,6 @@ import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@ -53,6 +53,7 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig<ShipDetail
|
||||
private final BatchDateService batchDateService;
|
||||
private final BatchApiLogService batchApiLogService;
|
||||
private final BatchFailedRecordService batchFailedRecordService;
|
||||
private final BatchFailedRecordRepository batchFailedRecordRepository;
|
||||
private final JobExecutionListener autoRetryJobExecutionListener;
|
||||
|
||||
@Value("${app.batch.ship-api.url}")
|
||||
@ -91,6 +92,7 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig<ShipDetail
|
||||
BatchDateService batchDateService,
|
||||
BatchApiLogService batchApiLogService,
|
||||
BatchFailedRecordService batchFailedRecordService,
|
||||
BatchFailedRecordRepository batchFailedRecordRepository,
|
||||
@Qualifier("autoRetryJobExecutionListener") JobExecutionListener autoRetryJobExecutionListener) {
|
||||
super(jobRepository, transactionManager);
|
||||
this.shipDetailDataProcessor = shipDetailDataProcessor;
|
||||
@ -102,6 +104,7 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig<ShipDetail
|
||||
this.batchDateService = batchDateService;
|
||||
this.batchApiLogService = batchApiLogService;
|
||||
this.batchFailedRecordService = batchFailedRecordService;
|
||||
this.batchFailedRecordRepository = batchFailedRecordRepository;
|
||||
this.autoRetryJobExecutionListener = autoRetryJobExecutionListener;
|
||||
}
|
||||
|
||||
@ -134,13 +137,13 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig<ShipDetail
|
||||
|
||||
/**
|
||||
* Retry 모드 판별 Decider
|
||||
* retryRecordKeys 파라미터가 존재하면 RETRY, 없으면 NORMAL 반환
|
||||
* executionMode가 RECOLLECT이면 RETRY, 없으면 NORMAL 반환
|
||||
*/
|
||||
@Bean
|
||||
public JobExecutionDecider retryModeDecider() {
|
||||
return (jobExecution, stepExecution) -> {
|
||||
String retryKeys = jobExecution.getJobParameters().getString("retryRecordKeys");
|
||||
if (retryKeys != null && !retryKeys.isBlank()) {
|
||||
String executionMode = jobExecution.getJobParameters().getString("executionMode");
|
||||
if ("RECOLLECT".equals(executionMode)) {
|
||||
log.info("[ShipDetailUpdateJob] Decider: RETRY 모드 - LAST_EXECUTION 업데이트 스킵");
|
||||
return new FlowExecutionStatus("RETRY");
|
||||
}
|
||||
@ -160,7 +163,7 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig<ShipDetail
|
||||
public ShipDetailUpdateDataReader shipDetailUpdateDataReader(
|
||||
@Value("#{stepExecution.jobExecution.id}") Long jobExecutionId,
|
||||
@Value("#{stepExecution.id}") Long stepExecutionId,
|
||||
@Value("#{jobParameters['retryRecordKeys']}") String retryRecordKeysParam,
|
||||
@Value("#{jobParameters['executionMode']}") String executionMode,
|
||||
@Value("#{jobParameters['sourceStepExecutionId']}") String sourceStepExecutionIdParam
|
||||
) {
|
||||
ShipDetailUpdateDataReader reader = new ShipDetailUpdateDataReader(
|
||||
@ -170,20 +173,21 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig<ShipDetail
|
||||
);
|
||||
reader.setExecutionIds(jobExecutionId, stepExecutionId);
|
||||
|
||||
// Retry 모드: retryRecordKeys 파라미터가 있으면 주입
|
||||
if (retryRecordKeysParam != null && !retryRecordKeysParam.isBlank()) {
|
||||
List<String> retryKeys = Arrays.stream(retryRecordKeysParam.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
reader.setRetryRecordKeys(retryKeys);
|
||||
// RECOLLECT 모드: DB에서 실패 키를 직접 조회하여 주입
|
||||
if ("RECOLLECT".equals(executionMode) && sourceStepExecutionIdParam != null && !sourceStepExecutionIdParam.isBlank()) {
|
||||
Long sourceStepExecutionId = Long.parseLong(sourceStepExecutionIdParam);
|
||||
List<String> retryKeys = batchFailedRecordRepository.findFailedRecordKeysByStepExecutionId(
|
||||
"ShipDetailUpdateJob", sourceStepExecutionId);
|
||||
|
||||
if (sourceStepExecutionIdParam != null && !sourceStepExecutionIdParam.isBlank()) {
|
||||
reader.setSourceStepExecutionId(Long.parseLong(sourceStepExecutionIdParam));
|
||||
if (!retryKeys.isEmpty()) {
|
||||
reader.setRetryRecordKeys(retryKeys);
|
||||
reader.setSourceStepExecutionId(sourceStepExecutionId);
|
||||
log.info("[ShipDetailUpdateJob] Retry 모드 활성화: DB에서 {} 건의 실패 키 조회, sourceStepExecutionId: {}",
|
||||
retryKeys.size(), sourceStepExecutionId);
|
||||
} else {
|
||||
log.warn("[ShipDetailUpdateJob] RECOLLECT 모드이나 DB에서 실패 키를 찾을 수 없음 (sourceStepExecutionId: {})",
|
||||
sourceStepExecutionId);
|
||||
}
|
||||
|
||||
log.info("[ShipDetailUpdateJob] Retry 모드 활성화: {} 건의 IMO 대상, sourceStepExecutionId: {}",
|
||||
retryKeys.size(), sourceStepExecutionIdParam);
|
||||
}
|
||||
|
||||
return reader;
|
||||
|
||||
@ -302,7 +302,7 @@ public class ShipDetailUpdateDataReader extends BaseApiReader<ShipDetailDto> {
|
||||
getJobExecutionId(),
|
||||
getStepExecutionId(),
|
||||
failedImoNumbers,
|
||||
maxRetryCount,
|
||||
true,
|
||||
lastErrorMessage
|
||||
);
|
||||
} else {
|
||||
@ -313,17 +313,17 @@ public class ShipDetailUpdateDataReader extends BaseApiReader<ShipDetailDto> {
|
||||
log.info("[{}] [RETRY MODE] 결과: 성공 {} 건, 재실패 {} 건",
|
||||
getReaderName(), successCount, failedImoNumbers.size());
|
||||
} else {
|
||||
// 일반 모드: 기존 로직
|
||||
// 일반 모드: 동기 저장 (자동 재수집 트리거 전에 커밋 보장)
|
||||
if (!failedImoNumbers.isEmpty()) {
|
||||
log.warn("[{}] 최종 실패 IMO 건수: {} 건", getReaderName(), failedImoNumbers.size());
|
||||
log.warn("[{}] 실패 IMO 목록: {}", getReaderName(), failedImoNumbers);
|
||||
|
||||
batchFailedRecordService.saveFailedRecords(
|
||||
batchFailedRecordService.saveFailedRecordsSync(
|
||||
"ShipDetailUpdateJob",
|
||||
getJobExecutionId(),
|
||||
getStepExecutionId(),
|
||||
failedImoNumbers,
|
||||
maxRetryCount,
|
||||
false,
|
||||
lastErrorMessage
|
||||
);
|
||||
} else {
|
||||
|
||||
@ -10,7 +10,9 @@ import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ -20,8 +22,8 @@ public class BatchFailedRecordService {
|
||||
private final BatchFailedRecordRepository batchFailedRecordRepository;
|
||||
|
||||
/**
|
||||
* 실패한 레코드 목록을 비동기로 DB에 저장합니다.
|
||||
* REQUIRES_NEW를 사용하여 메인 배치 트랜잭션과 독립적으로 저장합니다.
|
||||
* 실패 레코드를 비동기로 저장/업데이트합니다.
|
||||
* 동일 (jobName, recordKey)에 FAILED 레코드가 이미 존재하면 실행 정보만 갱신합니다.
|
||||
*/
|
||||
@Async("apiLogExecutor")
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
@ -30,27 +32,83 @@ public class BatchFailedRecordService {
|
||||
Long jobExecutionId,
|
||||
Long stepExecutionId,
|
||||
List<String> failedRecordKeys,
|
||||
int retryCount,
|
||||
boolean isRetryMode,
|
||||
String errorMessage
|
||||
) {
|
||||
doSaveOrUpdateFailedRecords(jobName, jobExecutionId, stepExecutionId,
|
||||
failedRecordKeys, isRetryMode, errorMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실패 레코드를 동기적으로 저장/업데이트합니다.
|
||||
* 자동 재수집 트리거 전에 실패 레코드가 반드시 커밋되어야 하는 경우 사용합니다.
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void saveFailedRecordsSync(
|
||||
String jobName,
|
||||
Long jobExecutionId,
|
||||
Long stepExecutionId,
|
||||
List<String> failedRecordKeys,
|
||||
boolean isRetryMode,
|
||||
String errorMessage
|
||||
) {
|
||||
doSaveOrUpdateFailedRecords(jobName, jobExecutionId, stepExecutionId,
|
||||
failedRecordKeys, isRetryMode, errorMessage);
|
||||
}
|
||||
|
||||
private void doSaveOrUpdateFailedRecords(
|
||||
String jobName,
|
||||
Long jobExecutionId,
|
||||
Long stepExecutionId,
|
||||
List<String> failedRecordKeys,
|
||||
boolean isRetryMode,
|
||||
String errorMessage
|
||||
) {
|
||||
try {
|
||||
List<BatchFailedRecord> records = failedRecordKeys.stream()
|
||||
.map(recordKey -> BatchFailedRecord.builder()
|
||||
.jobName(jobName)
|
||||
.jobExecutionId(jobExecutionId)
|
||||
.stepExecutionId(stepExecutionId)
|
||||
.recordKey(recordKey)
|
||||
.errorMessage(errorMessage)
|
||||
.retryCount(retryCount)
|
||||
.status("FAILED")
|
||||
.build())
|
||||
.toList();
|
||||
// 이미 FAILED 상태인 기존 레코드 키 조회
|
||||
Set<String> existingKeys = new HashSet<>(
|
||||
batchFailedRecordRepository.findExistingFailedKeys(jobName, failedRecordKeys));
|
||||
|
||||
batchFailedRecordRepository.saveAll(records);
|
||||
log.info("실패 레코드 {} 건 저장 완료 (job: {}, executionId: {})",
|
||||
records.size(), jobName, jobExecutionId);
|
||||
// 기존 레코드 업데이트 (실행 정보 갱신)
|
||||
List<String> keysToUpdate = failedRecordKeys.stream()
|
||||
.filter(existingKeys::contains)
|
||||
.toList();
|
||||
if (!keysToUpdate.isEmpty()) {
|
||||
int updated;
|
||||
if (isRetryMode) {
|
||||
updated = batchFailedRecordRepository.incrementRetryAndUpdateFailedRecords(
|
||||
jobName, keysToUpdate, jobExecutionId, stepExecutionId, errorMessage);
|
||||
} else {
|
||||
updated = batchFailedRecordRepository.updateFailedRecordExecutions(
|
||||
jobName, keysToUpdate, jobExecutionId, stepExecutionId, errorMessage);
|
||||
}
|
||||
log.info("실패 레코드 {} 건 업데이트 완료 (job: {}, retryMode: {})",
|
||||
updated, jobName, isRetryMode);
|
||||
}
|
||||
|
||||
// 신규 레코드 INSERT
|
||||
List<String> newKeys = failedRecordKeys.stream()
|
||||
.filter(key -> !existingKeys.contains(key))
|
||||
.toList();
|
||||
if (!newKeys.isEmpty()) {
|
||||
int initialRetryCount = isRetryMode ? 1 : 0;
|
||||
List<BatchFailedRecord> newRecords = newKeys.stream()
|
||||
.map(recordKey -> BatchFailedRecord.builder()
|
||||
.jobName(jobName)
|
||||
.jobExecutionId(jobExecutionId)
|
||||
.stepExecutionId(stepExecutionId)
|
||||
.recordKey(recordKey)
|
||||
.errorMessage(errorMessage)
|
||||
.retryCount(initialRetryCount)
|
||||
.status("FAILED")
|
||||
.build())
|
||||
.toList();
|
||||
batchFailedRecordRepository.saveAll(newRecords);
|
||||
log.info("실패 레코드 {} 건 신규 저장 완료 (job: {}, retryCount: {})",
|
||||
newRecords.size(), jobName, initialRetryCount);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("실패 레코드 저장 실패: {}", e.getMessage());
|
||||
log.error("실패 레코드 저장/업데이트 실패: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,6 +125,20 @@ public class BatchFailedRecordService {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* FAILED 상태 레코드의 retryCount를 0으로 초기화합니다.
|
||||
* 재시도 횟수를 초과한 레코드를 다시 자동 재수집 대상으로 만듭니다.
|
||||
*/
|
||||
@Transactional
|
||||
public int resetRetryCount(List<Long> ids) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int reset = batchFailedRecordRepository.resetRetryCount(ids);
|
||||
log.info("실패 레코드 retryCount 초기화: {} 건", reset);
|
||||
return reset;
|
||||
}
|
||||
|
||||
/**
|
||||
* 재수집 성공 건을 RESOLVED로 처리합니다.
|
||||
* 원본 stepExecutionId로 범위를 제한하여 해당 Step의 실패 건만 RESOLVED 처리합니다.
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user