fix(batch): 자동 재수집 파라미터 오버플로우 수정 및 실패 레코드 관리 개선 #38
@ -26,8 +26,12 @@
|
|||||||
- tb_ship_main_info, core20 테이블 mmsi 컬럼 업데이트 추가 (#28)
|
- tb_ship_main_info, core20 테이블 mmsi 컬럼 업데이트 추가 (#28)
|
||||||
- 자동 재수집 및 재수집 프로세스 전면 개선 (#30)
|
- 자동 재수집 및 재수집 프로세스 전면 개선 (#30)
|
||||||
- 배치 작업 목록 UX 개선: 상태 필터, 카드/테이블 뷰, 정렬, 실행 중 강조 (#33)
|
- 배치 작업 목록 UX 개선: 상태 필터, 카드/테이블 뷰, 정렬, 실행 중 강조 (#33)
|
||||||
|
- 재시도 초과 레코드 초기화 API/UI 추가
|
||||||
|
|
||||||
### 수정
|
### 수정
|
||||||
|
- 자동 재수집 JobParameter 오버플로우 수정 (VARCHAR 2500 제한 해결)
|
||||||
|
- retryCount 세마틱 오류 수정 (0부터 시작, 재수집 실패 시 증가)
|
||||||
|
- 실패 레코드 저장 타이밍 경합 해결 (동기 저장으로 변경)
|
||||||
- ChnPrmShip 캐시 갱신 조건 완화 및 스케줄 이전 실행 시간 표시 (#3)
|
- ChnPrmShip 캐시 갱신 조건 완화 및 스케줄 이전 실행 시간 표시 (#3)
|
||||||
- 재수집 관리 및 이력 추가 (#4)
|
- 재수집 관리 및 이력 추가 (#4)
|
||||||
- 재수집 중복 실행 문제 해결 (#9)
|
- 재수집 중복 실행 문제 해결 (#9)
|
||||||
@ -37,6 +41,8 @@
|
|||||||
- 타임라인 상세 화면 이동 오류 수정 및 실행 중 작업 상세 버튼 추가 (#34)
|
- 타임라인 상세 화면 이동 오류 수정 및 실행 중 작업 상세 버튼 추가 (#34)
|
||||||
|
|
||||||
### 변경
|
### 변경
|
||||||
|
- 실패 레코드 Upsert 패턴 적용 (동일 키 중복 방지)
|
||||||
|
- 재시도 상태 배지 표시 (대기/재시도 N/3/재시도 초과)
|
||||||
- 미사용 Dead Code 정리 (~1,200 LOC 삭제)
|
- 미사용 Dead Code 정리 (~1,200 LOC 삭제)
|
||||||
|
|
||||||
### 기타
|
### 기타
|
||||||
@ -46,3 +52,4 @@
|
|||||||
- CLAUDE_BOT_TOKEN 갱신 (#26)
|
- CLAUDE_BOT_TOKEN 갱신 (#26)
|
||||||
- 팀 글로벌 워크플로우 1.5.0 동기화
|
- 팀 글로벌 워크플로우 1.5.0 동기화
|
||||||
- 팀 워크플로우 v1.6.1 동기화
|
- 팀 워크플로우 v1.6.1 동기화
|
||||||
|
- 실행 확인 모달 시작/종료일시 항목 제거
|
||||||
|
|||||||
@ -8,10 +8,9 @@
|
|||||||
| **대상** | 실패한 record key | 실패한 record key | 날짜 범위 전체 |
|
| **대상** | 실패한 record key | 실패한 record key | 날짜 범위 전체 |
|
||||||
| `executionMode` | RECOLLECT | RECOLLECT | RECOLLECT |
|
| `executionMode` | RECOLLECT | RECOLLECT | RECOLLECT |
|
||||||
| `executor` | AUTO_RETRY | MANUAL_RETRY | MANUAL |
|
| `executor` | AUTO_RETRY | MANUAL_RETRY | MANUAL |
|
||||||
| `retryRecordKeys` | 있음 (콤마 구분) | 있음 (콤마 구분) | 없음 |
|
|
||||||
| `sourceStepExecutionId` | 있음 | 있음 | 없음 |
|
| `sourceStepExecutionId` | 있음 | 있음 | 없음 |
|
||||||
| `apiKey` | 있음 (ExecutionContext) | 없음 (resolveApiKey fallback) | 있음 (파라미터) |
|
| `apiKey` | 있음 (ExecutionContext) | 없음 (resolveApiKey fallback) | 있음 (파라미터) |
|
||||||
| **Reader 모드** | retry (키 기반) | retry (키 기반) | normal (API 전체 호출) |
|
| **Reader 모드** | retry (DB에서 키 조회) | retry (DB에서 키 조회) | normal (API 전체 호출) |
|
||||||
| **last_success_date** | Tasklet 스킵 → 불변 | Tasklet 스킵 → 불변 | Tasklet 실행 → 갱신 → 원복 |
|
| **last_success_date** | Tasklet 스킵 → 불변 | Tasklet 스킵 → 불변 | Tasklet 실행 → 갱신 → 원복 |
|
||||||
| **이력 날짜범위** | NULL / NULL | NULL / NULL | from / to |
|
| **이력 날짜범위** | NULL / NULL | NULL / NULL | from / to |
|
||||||
| **중복 검사** | 안함 | 안함 | 함 (findOverlappingHistories) |
|
| **중복 검사** | 안함 | 안함 | 함 (findOverlappingHistories) |
|
||||||
@ -29,7 +28,9 @@
|
|||||||
│ └─ failedImoNumbers에 누적
|
│ └─ failedImoNumbers에 누적
|
||||||
│
|
│
|
||||||
├─ Reader.afterFetch(null) [일반 모드]
|
├─ 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 저장
|
│ └─ finally: ExecutionContext에 failedRecordKeys/stepId/apiKey 저장
|
||||||
│
|
│
|
||||||
├─ [Job COMPLETED]
|
├─ [Job COMPLETED]
|
||||||
@ -39,7 +40,8 @@
|
|||||||
│ ├─ status == COMPLETED ✓
|
│ ├─ status == COMPLETED ✓
|
||||||
│ ├─ 모든 Step의 failedRecordKeys → LinkedHashSet 병합
|
│ ├─ 모든 Step의 failedRecordKeys → LinkedHashSet 병합
|
||||||
│ ├─ findExceededRetryKeys() → retryCount >= 3 인 키 제외
|
│ ├─ findExceededRetryKeys() → retryCount >= 3 인 키 제외
|
||||||
│ └─ AutoRetryTriggerService.triggerRetryAsync() [autoRetryExecutor 스레드]
|
│ └─ AutoRetryTriggerService.triggerRetryAsync(jobName, failedCount, stepId, apiKey)
|
||||||
|
│ └─ JobParameter에 sourceStepExecutionId만 전달 (키 목록은 DB에서 조회)
|
||||||
│
|
│
|
||||||
├─ [재수집 Job 시작 (executionMode=RECOLLECT, executor=AUTO_RETRY)]
|
├─ [재수집 Job 시작 (executionMode=RECOLLECT, executor=AUTO_RETRY)]
|
||||||
│
|
│
|
||||||
@ -47,14 +49,20 @@
|
|||||||
│ ├─ last_success_date 저장 (ExecutionContext)
|
│ ├─ last_success_date 저장 (ExecutionContext)
|
||||||
│ └─ recordStart() ──→ [batch_recollection_history] INSERT (STARTED, 날짜 NULL)
|
│ └─ recordStart() ──→ [batch_recollection_history] INSERT (STARTED, 날짜 NULL)
|
||||||
│
|
│
|
||||||
|
├─ ShipDetailUpdateJobConfig.shipDetailUpdateDataReader() [@StepScope 빈 초기화]
|
||||||
|
│ └─ BatchFailedRecordRepository.findFailedRecordKeysByStepExecutionId() → DB에서 실패 키 조회
|
||||||
|
│
|
||||||
├─ Reader.beforeFetch() [retry 모드]
|
├─ Reader.beforeFetch() [retry 모드]
|
||||||
│ └─ allImoNumbers = retryRecordKeys (API 호출 없이 키 목록 직접 사용)
|
│ └─ allImoNumbers = retryRecordKeys (API 호출 없이 키 목록 직접 사용)
|
||||||
│
|
│
|
||||||
├─ Reader.afterFetch(null) [retry 모드]
|
├─ Reader.afterFetch(null) [retry 모드]
|
||||||
│ ├─ resolveSuccessfulRetries() ──→ [batch_failed_record] UPDATE (RESOLVED)
|
│ ├─ 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()
|
├─ RecollectionJobExecutionListener.afterJob()
|
||||||
│ ├─ recordCompletion() ──→ [batch_recollection_history] UPDATE (COMPLETED/FAILED)
|
│ ├─ recordCompletion() ──→ [batch_recollection_history] UPDATE (COMPLETED/FAILED)
|
||||||
@ -73,8 +81,7 @@
|
|||||||
│
|
│
|
||||||
├─ batchApi.retryFailedRecords(jobName, recordKeys, stepExecutionId)
|
├─ batchApi.retryFailedRecords(jobName, recordKeys, stepExecutionId)
|
||||||
│ └─ POST /api/batch/jobs/{jobName}/execute
|
│ └─ POST /api/batch/jobs/{jobName}/execute
|
||||||
│ ?retryRecordKeys=IMO1,IMO2
|
│ ?sourceStepExecutionId=123
|
||||||
│ &sourceStepExecutionId=123
|
|
||||||
│ &executionMode=RECOLLECT
|
│ &executionMode=RECOLLECT
|
||||||
│ &executor=MANUAL_RETRY
|
│ &executor=MANUAL_RETRY
|
||||||
│ &reason=실패 건 수동 재수집 (N건)
|
│ &reason=실패 건 수동 재수집 (N건)
|
||||||
@ -84,8 +91,9 @@
|
|||||||
│
|
│
|
||||||
├─ [이후 흐름은 AUTO_RETRY와 동일]
|
├─ [이후 흐름은 AUTO_RETRY와 동일]
|
||||||
│ ├─ RecollectionJobExecutionListener.beforeJob() → 이력 INSERT
|
│ ├─ RecollectionJobExecutionListener.beforeJob() → 이력 INSERT
|
||||||
|
│ ├─ Reader 빈 초기화 시 DB에서 실패 키 조회
|
||||||
│ ├─ Reader [retry 모드] → 키 기반 재처리
|
│ ├─ Reader [retry 모드] → 키 기반 재처리
|
||||||
│ ├─ retryModeDecider → "RETRY" → Tasklet 스킵
|
│ ├─ retryModeDecider → executionMode="RECOLLECT" → "RETRY" → Tasklet 스킵
|
||||||
│ └─ RecollectionJobExecutionListener.afterJob() → 이력 UPDATE + 복원
|
│ └─ RecollectionJobExecutionListener.afterJob() → 이력 UPDATE + 복원
|
||||||
│
|
│
|
||||||
└─ 성공 시 UI에서 새 실행 상세 화면으로 이동
|
└─ 성공 시 UI에서 새 실행 상세 화면으로 이동
|
||||||
@ -124,20 +132,96 @@
|
|||||||
|
|
||||||
## 3. 테이블 영향 매트릭스
|
## 3. 테이블 영향 매트릭스
|
||||||
|
|
||||||
| 테이블 | AUTO_RETRY | MANUAL_RETRY | MANUAL(날짜) | UI 일괄 RESOLVED |
|
| 테이블 | AUTO_RETRY | MANUAL_RETRY | MANUAL(날짜) | UI 일괄 RESOLVED | UI 재시도 초기화 |
|
||||||
|--------|-----------|--------------|-------------|-----------------|
|
|--------|-----------|--------------|-------------|-----------------|-----------------|
|
||||||
| `batch_failed_record` | INSERT(FAILED) + UPDATE(RESOLVED) | INSERT(FAILED) + UPDATE(RESOLVED) | - | UPDATE(RESOLVED) |
|
| `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_recollection_history` | INSERT + UPDATE | INSERT + UPDATE | INSERT + UPDATE | - | - |
|
||||||
| `batch_collection_period` | READ (apiKeyName 조회) | READ (apiKeyName 조회) | READ/WRITE (날짜범위) | - |
|
| `batch_collection_period` | READ (apiKeyName 조회) | READ (apiKeyName 조회) | READ/WRITE (날짜범위) | - | - |
|
||||||
| `batch_last_execution` | READ (복원용) | READ (복원용) | READ → WRITE(갱신) → WRITE(복원) | - |
|
| `batch_last_execution` | READ (복원용) | READ (복원용) | READ → WRITE(갱신) → WRITE(복원) | - | - |
|
||||||
| `batch_job_execution` | INSERT (Spring Batch 자동) | INSERT (Spring Batch 자동) | INSERT (Spring Batch 자동) | - |
|
| `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
|
Guard 1: AutoRetryJobExecutionListener
|
||||||
└─ executionMode == "RECOLLECT" → 즉시 리턴 (재수집 Job에서는 자동 재수집 트리거 안 함)
|
└─ executionMode == "RECOLLECT" → 즉시 리턴 (재수집 Job에서는 자동 재수집 트리거 안 함)
|
||||||
@ -146,47 +230,53 @@ Guard 2: findExceededRetryKeys()
|
|||||||
└─ retryCount >= 3 인 키 → mergedKeys에서 제거
|
└─ retryCount >= 3 인 키 → mergedKeys에서 제거
|
||||||
└─ 남은 키 없으면 → 재수집 자체 스킵
|
└─ 남은 키 없으면 → 재수집 자체 스킵
|
||||||
|
|
||||||
|
Guard 3: Reader afterFetch [retry 모드]
|
||||||
|
└─ ExecutionContext에 failedRecordKeys 미기록 → 리스너가 감지하지 못함
|
||||||
|
|
||||||
[시나리오]
|
[시나리오]
|
||||||
일반 실행 → 실패 3건 → 자동 재수집(retryCount=3 저장)
|
일반 실행 → 실패 3건 → batch_failed_record INSERT (retryCount=0)
|
||||||
→ 재수집 Job은 executionMode=RECOLLECT → Guard 1에서 차단
|
→ 자동 재수집 1차 → 재실패 → retryCount=1
|
||||||
|
→ 자동 재수집 2차 → 재실패 → retryCount=2
|
||||||
|
→ 자동 재수집 3차 → 재실패 → retryCount=3
|
||||||
→ 다음 정상 실행에서 같은 키 다시 실패해도 retryCount >= 3 → Guard 2에서 차단
|
→ 다음 정상 실행에서 같은 키 다시 실패해도 retryCount >= 3 → Guard 2에서 차단
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 프론트엔드 UI 진입점
|
## 6. 프론트엔드 UI 진입점
|
||||||
|
|
||||||
| 화면 | 컴포넌트/영역 | 기능 |
|
| 화면 | 컴포넌트/영역 | 기능 |
|
||||||
|------|--------------|------|
|
|------|--------------|------|
|
||||||
| **Executions** | failedRecordCount 뱃지 | COMPLETED 상태 + 미해결 실패건 > 0 → amber 뱃지 |
|
| **Executions** | failedRecordCount 뱃지 | COMPLETED 상태 + 미해결 실패건 > 0 → amber 뱃지 |
|
||||||
| **ExecutionDetail** | FailedRecordsToggle | 실패 레코드 목록 + "재수집 실행" + "일괄 RESOLVED" 버튼 |
|
| **ExecutionDetail** | FailedRecordsToggle | 실패 레코드 목록 + 재시도 상태 배지 + "재시도 초기화" / "재수집 실행" / "일괄 RESOLVED" 버튼 |
|
||||||
| **RecollectDetail** | FailedRecordsToggle | 재수집 상세 내 실패 레코드 + 동일 버튼 |
|
| **RecollectDetail** | FailedRecordsToggle | 재수집 상세 내 실패 레코드 + 동일 버튼 |
|
||||||
| **Recollects** | 이력 목록 + 수집기간 관리 | 검색/필터 + CSV 내보내기 + 재수집 실행 + 기간 편집 |
|
| **Recollects** | 이력 목록 + 수집기간 관리 | 검색/필터 + CSV 내보내기 + 재수집 실행 + 기간 편집 |
|
||||||
| **Dashboard** | 재수집 현황 위젯 | 통계 카드 5개 + 최근 5건 테이블 |
|
| **Dashboard** | 재수집 현황 위젯 | 통계 카드 5개 + 최근 5건 테이블 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 주요 파일 맵
|
## 7. 주요 파일 맵
|
||||||
|
|
||||||
### 백엔드
|
### 백엔드
|
||||||
|
|
||||||
| 파일 | 역할 |
|
| 파일 | 역할 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `ShipDetailUpdateDataReader.java` | API 호출 + 실패 감지 + ExecutionContext 저장 |
|
| `ShipDetailUpdateDataReader.java` | API 호출 + 실패 감지 + ExecutionContext 저장 |
|
||||||
| `ShipDetailUpdateJobConfig.java` | Job/Step 구성 + retryModeDecider + 리스너 등록 |
|
| `ShipDetailUpdateJobConfig.java` | Job/Step 구성 + retryModeDecider(executionMode 기반) + DB에서 실패 키 조회 |
|
||||||
| `AutoRetryJobExecutionListener.java` | Job 완료 후 실패 키 병합 + retryCount 필터 + 트리거 |
|
| `AutoRetryJobExecutionListener.java` | Job 완료 후 실패 키 병합 + retryCount 필터 + 트리거 |
|
||||||
| `AutoRetryTriggerService.java` | @Async 비동기 Job 재실행 (autoRetryExecutor) |
|
| `AutoRetryTriggerService.java` | @Async 비동기 Job 재실행 (sourceStepExecutionId만 전달) |
|
||||||
| `RecollectionJobExecutionListener.java` | 재수집 이력 기록 + last_success_date 백업/복원 |
|
| `RecollectionJobExecutionListener.java` | 재수집 이력 기록 + last_success_date 백업/복원 |
|
||||||
| `RecollectionHistoryService.java` | 이력 CRUD + 통계 + 수집기간 관리 |
|
| `RecollectionHistoryService.java` | 이력 CRUD + 통계 + 수집기간 관리 |
|
||||||
| `BatchFailedRecordService.java` | 실패 레코드 저장/RESOLVED 처리 |
|
| `BatchFailedRecordService.java` | 실패 레코드 Upsert(동기/비동기) + RESOLVED 처리 |
|
||||||
|
| `BatchFailedRecordRepository.java` | 실패 키 조회 + 벌크 UPDATE(retryCount 유지/증가/초기화) + RESOLVED |
|
||||||
| `BatchService.java` | Job 실행 + failedRecordCount 조회 |
|
| `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 뱃지 |
|
| `Executions.tsx` | 실행 목록 + failedRecordCount amber 뱃지 |
|
||||||
| `ExecutionDetail.tsx` | 실행 상세 + FailedRecordsToggle (재수집/RESOLVED) |
|
| `ExecutionDetail.tsx` | 실행 상세 + FailedRecordsToggle (재수집/RESOLVED) |
|
||||||
| `RecollectDetail.tsx` | 재수집 상세 + FailedRecordsToggle (재수집/RESOLVED) |
|
| `RecollectDetail.tsx` | 재수집 상세 + FailedRecordsToggle (재수집/RESOLVED) |
|
||||||
|
|||||||
@ -498,6 +498,10 @@ export const batchApi = {
|
|||||||
postJson<{ success: boolean; message: string; resolvedCount?: number }>(
|
postJson<{ success: boolean; message: string; resolvedCount?: number }>(
|
||||||
`${BASE}/failed-records/resolve`, { ids }),
|
`${BASE}/failed-records/resolve`, { ids }),
|
||||||
|
|
||||||
|
resetRetryCount: (ids: number[]) =>
|
||||||
|
postJson<{ success: boolean; message: string; resetCount?: number }>(
|
||||||
|
`${BASE}/failed-records/reset-retry`, { ids }),
|
||||||
|
|
||||||
exportRecollectionHistories: (params: {
|
exportRecollectionHistories: (params: {
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
jobName?: string;
|
jobName?: string;
|
||||||
|
|||||||
@ -353,6 +353,8 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
|||||||
const [showResolveConfirm, setShowResolveConfirm] = useState(false);
|
const [showResolveConfirm, setShowResolveConfirm] = useState(false);
|
||||||
const [retrying, setRetrying] = useState(false);
|
const [retrying, setRetrying] = useState(false);
|
||||||
const [resolving, setResolving] = useState(false);
|
const [resolving, setResolving] = useState(false);
|
||||||
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
|
const [resetting, setResetting] = useState(false);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const navigate = useNavigate();
|
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 () => {
|
const handleRetry = async () => {
|
||||||
setRetrying(true);
|
setRetrying(true);
|
||||||
try {
|
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 (
|
return (
|
||||||
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
|
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -422,6 +449,17 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
|||||||
|
|
||||||
{failedRecords.length > 0 && (
|
{failedRecords.length > 0 && (
|
||||||
<div className="flex items-center gap-1.5">
|
<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
|
<button
|
||||||
onClick={() => setShowResolveConfirm(true)}
|
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"
|
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 || ''}>
|
<td className="px-2 py-1.5 text-red-600 max-w-[200px] truncate" title={record.errorMessage || ''}>
|
||||||
{record.errorMessage || '-'}
|
{record.errorMessage || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5 text-center text-red-900">
|
<td className="px-2 py-1.5 text-center">
|
||||||
{record.retryCount}
|
{(() => {
|
||||||
|
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>
|
||||||
<td className="px-2 py-1.5 text-center">
|
<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)}`}>
|
<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>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,8 +60,6 @@ export default function Jobs() {
|
|||||||
const [executeModalOpen, setExecuteModalOpen] = useState(false);
|
const [executeModalOpen, setExecuteModalOpen] = useState(false);
|
||||||
const [targetJob, setTargetJob] = useState('');
|
const [targetJob, setTargetJob] = useState('');
|
||||||
const [executing, setExecuting] = useState(false);
|
const [executing, setExecuting] = useState(false);
|
||||||
const [startDate, setStartDate] = useState('');
|
|
||||||
const [stopDate, setStopDate] = useState('');
|
|
||||||
|
|
||||||
|
|
||||||
const loadJobs = useCallback(async () => {
|
const loadJobs = useCallback(async () => {
|
||||||
@ -121,8 +119,6 @@ export default function Jobs() {
|
|||||||
|
|
||||||
const handleExecuteClick = (jobName: string) => {
|
const handleExecuteClick = (jobName: string) => {
|
||||||
setTargetJob(jobName);
|
setTargetJob(jobName);
|
||||||
setStartDate('');
|
|
||||||
setStopDate('');
|
|
||||||
setExecuteModalOpen(true);
|
setExecuteModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -130,14 +126,7 @@ export default function Jobs() {
|
|||||||
if (!targetJob) return;
|
if (!targetJob) return;
|
||||||
setExecuting(true);
|
setExecuting(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {};
|
const result = await batchApi.executeJob(targetJob);
|
||||||
if (startDate) params.startDate = startDate;
|
|
||||||
if (stopDate) params.stopDate = stopDate;
|
|
||||||
|
|
||||||
const result = await batchApi.executeJob(
|
|
||||||
targetJob,
|
|
||||||
Object.keys(params).length > 0 ? params : undefined,
|
|
||||||
);
|
|
||||||
showToast(
|
showToast(
|
||||||
result.message || `${targetJob} 실행 요청 완료`,
|
result.message || `${targetJob} 실행 요청 완료`,
|
||||||
'success',
|
'success',
|
||||||
@ -503,40 +492,7 @@ export default function Jobs() {
|
|||||||
"{targetJob}" 작업을 실행하시겠습니까?
|
"{targetJob}" 작업을 실행하시겠습니까?
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Date parameters */}
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
<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">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setExecuteModalOpen(false)}
|
onClick={() => setExecuteModalOpen(false)}
|
||||||
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg
|
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 [showResolveConfirm, setShowResolveConfirm] = useState(false);
|
||||||
const [retrying, setRetrying] = useState(false);
|
const [retrying, setRetrying] = useState(false);
|
||||||
const [resolving, setResolving] = useState(false);
|
const [resolving, setResolving] = useState(false);
|
||||||
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
|
const [resetting, setResetting] = useState(false);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const navigate = useNavigate();
|
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 () => {
|
const handleRetry = async () => {
|
||||||
setRetrying(true);
|
setRetrying(true);
|
||||||
try {
|
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 (
|
return (
|
||||||
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
|
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -473,6 +500,17 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
|
|||||||
|
|
||||||
{failedRecords.length > 0 && (
|
{failedRecords.length > 0 && (
|
||||||
<div className="flex items-center gap-1.5">
|
<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
|
<button
|
||||||
onClick={() => setShowResolveConfirm(true)}
|
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"
|
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 || ''}>
|
<td className="px-2 py-1.5 text-red-600 max-w-[200px] truncate" title={record.errorMessage || ''}>
|
||||||
{record.errorMessage || '-'}
|
{record.errorMessage || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5 text-center text-red-900">
|
<td className="px-2 py-1.5 text-center">
|
||||||
{record.retryCount}
|
{(() => {
|
||||||
|
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>
|
||||||
<td className="px-2 py-1.5 text-center">
|
<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)}`}>
|
<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>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -109,12 +109,11 @@ public class AutoRetryJobExecutionListener implements JobExecutionListener {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String mergedFailedKeys = String.join(",", mergedKeys);
|
|
||||||
log.info("[AutoRetry] {} Job 완료 후 실패 건 {}건 감지 → 자동 재수집 트리거",
|
log.info("[AutoRetry] {} Job 완료 후 실패 건 {}건 감지 → 자동 재수집 트리거",
|
||||||
jobName, mergedKeys.size());
|
jobName, mergedKeys.size());
|
||||||
|
|
||||||
// 합산된 키로 1회만 triggerRetryAsync 호출
|
// sourceStepExecutionId 기반으로 1회만 triggerRetryAsync 호출 (실패 키는 DB에서 직접 조회)
|
||||||
autoRetryTriggerService.triggerRetryAsync(
|
autoRetryTriggerService.triggerRetryAsync(
|
||||||
jobName, mergedFailedKeys, sourceStepExecutionId, apiKey);
|
jobName, mergedKeys.size(), sourceStepExecutionId, apiKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,7 +30,7 @@ public class AutoRetryTriggerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Async("autoRetryExecutor")
|
@Async("autoRetryExecutor")
|
||||||
public void triggerRetryAsync(String jobName, String failedKeys,
|
public void triggerRetryAsync(String jobName, int failedCount,
|
||||||
Long sourceStepExecutionId, String apiKey) {
|
Long sourceStepExecutionId, String apiKey) {
|
||||||
try {
|
try {
|
||||||
Job job = jobMap.get(jobName);
|
Job job = jobMap.get(jobName);
|
||||||
@ -41,7 +41,6 @@ public class AutoRetryTriggerService {
|
|||||||
|
|
||||||
JobParametersBuilder builder = new JobParametersBuilder()
|
JobParametersBuilder builder = new JobParametersBuilder()
|
||||||
.addLong("timestamp", System.currentTimeMillis())
|
.addLong("timestamp", System.currentTimeMillis())
|
||||||
.addString("retryRecordKeys", failedKeys)
|
|
||||||
.addString("sourceStepExecutionId", String.valueOf(sourceStepExecutionId))
|
.addString("sourceStepExecutionId", String.valueOf(sourceStepExecutionId))
|
||||||
.addString("executionMode", "RECOLLECT")
|
.addString("executionMode", "RECOLLECT")
|
||||||
.addString("reason", "자동 재수집 (실패 건 자동 처리)")
|
.addString("reason", "자동 재수집 (실패 건 자동 처리)")
|
||||||
@ -54,7 +53,7 @@ public class AutoRetryTriggerService {
|
|||||||
JobParameters retryParams = builder.toJobParameters();
|
JobParameters retryParams = builder.toJobParameters();
|
||||||
|
|
||||||
log.info("[AutoRetry] 재수집 Job 실행 시작: jobName={}, 실패건={}, sourceStepExecutionId={}",
|
log.info("[AutoRetry] 재수집 Job 실행 시작: jobName={}, 실패건={}, sourceStepExecutionId={}",
|
||||||
jobName, failedKeys.split(",").length, sourceStepExecutionId);
|
jobName, failedCount, sourceStepExecutionId);
|
||||||
|
|
||||||
JobExecution retryExecution = jobLauncher.run(job, retryParams);
|
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 ──────────────────────────────
|
// ── 재수집 이력 CSV 내보내기 API ──────────────────────────────
|
||||||
|
|
||||||
@Operation(summary = "재수집 이력 CSV 내보내기", description = "필터 조건으로 재수집 이력을 CSV 파일로 내보냅니다 (최대 10,000건)")
|
@Operation(summary = "재수집 이력 CSV 내보내기", description = "필터 조건으로 재수집 이력을 CSV 파일로 내보냅니다 (최대 10,000건)")
|
||||||
|
|||||||
@ -77,4 +77,60 @@ public interface BatchFailedRecordRepository extends JpaRepository<BatchFailedRe
|
|||||||
List<String> findExceededRetryKeys(@Param("jobName") String jobName,
|
List<String> findExceededRetryKeys(@Param("jobName") String jobName,
|
||||||
@Param("recordKeys") List<String> recordKeys,
|
@Param("recordKeys") List<String> recordKeys,
|
||||||
@Param("maxRetryCount") int maxRetryCount);
|
@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.processor.ShipDetailDataProcessor;
|
||||||
import com.snp.batch.jobs.shipdetail.batch.reader.ShipDetailUpdateDataReader;
|
import com.snp.batch.jobs.shipdetail.batch.reader.ShipDetailUpdateDataReader;
|
||||||
import com.snp.batch.jobs.shipdetail.batch.writer.ShipDetailDataWriter;
|
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.BatchApiLogService;
|
||||||
import com.snp.batch.service.BatchDateService;
|
import com.snp.batch.service.BatchDateService;
|
||||||
import com.snp.batch.service.BatchFailedRecordService;
|
import com.snp.batch.service.BatchFailedRecordService;
|
||||||
@ -37,7 +38,6 @@ import org.springframework.web.reactive.function.client.WebClient;
|
|||||||
|
|
||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@ -53,6 +53,7 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig<ShipDetail
|
|||||||
private final BatchDateService batchDateService;
|
private final BatchDateService batchDateService;
|
||||||
private final BatchApiLogService batchApiLogService;
|
private final BatchApiLogService batchApiLogService;
|
||||||
private final BatchFailedRecordService batchFailedRecordService;
|
private final BatchFailedRecordService batchFailedRecordService;
|
||||||
|
private final BatchFailedRecordRepository batchFailedRecordRepository;
|
||||||
private final JobExecutionListener autoRetryJobExecutionListener;
|
private final JobExecutionListener autoRetryJobExecutionListener;
|
||||||
|
|
||||||
@Value("${app.batch.ship-api.url}")
|
@Value("${app.batch.ship-api.url}")
|
||||||
@ -91,6 +92,7 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig<ShipDetail
|
|||||||
BatchDateService batchDateService,
|
BatchDateService batchDateService,
|
||||||
BatchApiLogService batchApiLogService,
|
BatchApiLogService batchApiLogService,
|
||||||
BatchFailedRecordService batchFailedRecordService,
|
BatchFailedRecordService batchFailedRecordService,
|
||||||
|
BatchFailedRecordRepository batchFailedRecordRepository,
|
||||||
@Qualifier("autoRetryJobExecutionListener") JobExecutionListener autoRetryJobExecutionListener) {
|
@Qualifier("autoRetryJobExecutionListener") JobExecutionListener autoRetryJobExecutionListener) {
|
||||||
super(jobRepository, transactionManager);
|
super(jobRepository, transactionManager);
|
||||||
this.shipDetailDataProcessor = shipDetailDataProcessor;
|
this.shipDetailDataProcessor = shipDetailDataProcessor;
|
||||||
@ -102,6 +104,7 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig<ShipDetail
|
|||||||
this.batchDateService = batchDateService;
|
this.batchDateService = batchDateService;
|
||||||
this.batchApiLogService = batchApiLogService;
|
this.batchApiLogService = batchApiLogService;
|
||||||
this.batchFailedRecordService = batchFailedRecordService;
|
this.batchFailedRecordService = batchFailedRecordService;
|
||||||
|
this.batchFailedRecordRepository = batchFailedRecordRepository;
|
||||||
this.autoRetryJobExecutionListener = autoRetryJobExecutionListener;
|
this.autoRetryJobExecutionListener = autoRetryJobExecutionListener;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,13 +137,13 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig<ShipDetail
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Retry 모드 판별 Decider
|
* Retry 모드 판별 Decider
|
||||||
* retryRecordKeys 파라미터가 존재하면 RETRY, 없으면 NORMAL 반환
|
* executionMode가 RECOLLECT이면 RETRY, 없으면 NORMAL 반환
|
||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public JobExecutionDecider retryModeDecider() {
|
public JobExecutionDecider retryModeDecider() {
|
||||||
return (jobExecution, stepExecution) -> {
|
return (jobExecution, stepExecution) -> {
|
||||||
String retryKeys = jobExecution.getJobParameters().getString("retryRecordKeys");
|
String executionMode = jobExecution.getJobParameters().getString("executionMode");
|
||||||
if (retryKeys != null && !retryKeys.isBlank()) {
|
if ("RECOLLECT".equals(executionMode)) {
|
||||||
log.info("[ShipDetailUpdateJob] Decider: RETRY 모드 - LAST_EXECUTION 업데이트 스킵");
|
log.info("[ShipDetailUpdateJob] Decider: RETRY 모드 - LAST_EXECUTION 업데이트 스킵");
|
||||||
return new FlowExecutionStatus("RETRY");
|
return new FlowExecutionStatus("RETRY");
|
||||||
}
|
}
|
||||||
@ -160,7 +163,7 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig<ShipDetail
|
|||||||
public ShipDetailUpdateDataReader shipDetailUpdateDataReader(
|
public ShipDetailUpdateDataReader shipDetailUpdateDataReader(
|
||||||
@Value("#{stepExecution.jobExecution.id}") Long jobExecutionId,
|
@Value("#{stepExecution.jobExecution.id}") Long jobExecutionId,
|
||||||
@Value("#{stepExecution.id}") Long stepExecutionId,
|
@Value("#{stepExecution.id}") Long stepExecutionId,
|
||||||
@Value("#{jobParameters['retryRecordKeys']}") String retryRecordKeysParam,
|
@Value("#{jobParameters['executionMode']}") String executionMode,
|
||||||
@Value("#{jobParameters['sourceStepExecutionId']}") String sourceStepExecutionIdParam
|
@Value("#{jobParameters['sourceStepExecutionId']}") String sourceStepExecutionIdParam
|
||||||
) {
|
) {
|
||||||
ShipDetailUpdateDataReader reader = new ShipDetailUpdateDataReader(
|
ShipDetailUpdateDataReader reader = new ShipDetailUpdateDataReader(
|
||||||
@ -170,20 +173,21 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig<ShipDetail
|
|||||||
);
|
);
|
||||||
reader.setExecutionIds(jobExecutionId, stepExecutionId);
|
reader.setExecutionIds(jobExecutionId, stepExecutionId);
|
||||||
|
|
||||||
// Retry 모드: retryRecordKeys 파라미터가 있으면 주입
|
// RECOLLECT 모드: DB에서 실패 키를 직접 조회하여 주입
|
||||||
if (retryRecordKeysParam != null && !retryRecordKeysParam.isBlank()) {
|
if ("RECOLLECT".equals(executionMode) && sourceStepExecutionIdParam != null && !sourceStepExecutionIdParam.isBlank()) {
|
||||||
List<String> retryKeys = Arrays.stream(retryRecordKeysParam.split(","))
|
Long sourceStepExecutionId = Long.parseLong(sourceStepExecutionIdParam);
|
||||||
.map(String::trim)
|
List<String> retryKeys = batchFailedRecordRepository.findFailedRecordKeysByStepExecutionId(
|
||||||
.filter(s -> !s.isEmpty())
|
"ShipDetailUpdateJob", sourceStepExecutionId);
|
||||||
.toList();
|
|
||||||
reader.setRetryRecordKeys(retryKeys);
|
|
||||||
|
|
||||||
if (sourceStepExecutionIdParam != null && !sourceStepExecutionIdParam.isBlank()) {
|
if (!retryKeys.isEmpty()) {
|
||||||
reader.setSourceStepExecutionId(Long.parseLong(sourceStepExecutionIdParam));
|
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;
|
return reader;
|
||||||
|
|||||||
@ -302,7 +302,7 @@ public class ShipDetailUpdateDataReader extends BaseApiReader<ShipDetailDto> {
|
|||||||
getJobExecutionId(),
|
getJobExecutionId(),
|
||||||
getStepExecutionId(),
|
getStepExecutionId(),
|
||||||
failedImoNumbers,
|
failedImoNumbers,
|
||||||
maxRetryCount,
|
true,
|
||||||
lastErrorMessage
|
lastErrorMessage
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@ -313,17 +313,17 @@ public class ShipDetailUpdateDataReader extends BaseApiReader<ShipDetailDto> {
|
|||||||
log.info("[{}] [RETRY MODE] 결과: 성공 {} 건, 재실패 {} 건",
|
log.info("[{}] [RETRY MODE] 결과: 성공 {} 건, 재실패 {} 건",
|
||||||
getReaderName(), successCount, failedImoNumbers.size());
|
getReaderName(), successCount, failedImoNumbers.size());
|
||||||
} else {
|
} else {
|
||||||
// 일반 모드: 기존 로직
|
// 일반 모드: 동기 저장 (자동 재수집 트리거 전에 커밋 보장)
|
||||||
if (!failedImoNumbers.isEmpty()) {
|
if (!failedImoNumbers.isEmpty()) {
|
||||||
log.warn("[{}] 최종 실패 IMO 건수: {} 건", getReaderName(), failedImoNumbers.size());
|
log.warn("[{}] 최종 실패 IMO 건수: {} 건", getReaderName(), failedImoNumbers.size());
|
||||||
log.warn("[{}] 실패 IMO 목록: {}", getReaderName(), failedImoNumbers);
|
log.warn("[{}] 실패 IMO 목록: {}", getReaderName(), failedImoNumbers);
|
||||||
|
|
||||||
batchFailedRecordService.saveFailedRecords(
|
batchFailedRecordService.saveFailedRecordsSync(
|
||||||
"ShipDetailUpdateJob",
|
"ShipDetailUpdateJob",
|
||||||
getJobExecutionId(),
|
getJobExecutionId(),
|
||||||
getStepExecutionId(),
|
getStepExecutionId(),
|
||||||
failedImoNumbers,
|
failedImoNumbers,
|
||||||
maxRetryCount,
|
false,
|
||||||
lastErrorMessage
|
lastErrorMessage
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -10,7 +10,9 @@ import org.springframework.transaction.annotation.Propagation;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@ -20,8 +22,8 @@ public class BatchFailedRecordService {
|
|||||||
private final BatchFailedRecordRepository batchFailedRecordRepository;
|
private final BatchFailedRecordRepository batchFailedRecordRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 실패한 레코드 목록을 비동기로 DB에 저장합니다.
|
* 실패 레코드를 비동기로 저장/업데이트합니다.
|
||||||
* REQUIRES_NEW를 사용하여 메인 배치 트랜잭션과 독립적으로 저장합니다.
|
* 동일 (jobName, recordKey)에 FAILED 레코드가 이미 존재하면 실행 정보만 갱신합니다.
|
||||||
*/
|
*/
|
||||||
@Async("apiLogExecutor")
|
@Async("apiLogExecutor")
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
@ -30,27 +32,83 @@ public class BatchFailedRecordService {
|
|||||||
Long jobExecutionId,
|
Long jobExecutionId,
|
||||||
Long stepExecutionId,
|
Long stepExecutionId,
|
||||||
List<String> failedRecordKeys,
|
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
|
String errorMessage
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
List<BatchFailedRecord> records = failedRecordKeys.stream()
|
// 이미 FAILED 상태인 기존 레코드 키 조회
|
||||||
.map(recordKey -> BatchFailedRecord.builder()
|
Set<String> existingKeys = new HashSet<>(
|
||||||
.jobName(jobName)
|
batchFailedRecordRepository.findExistingFailedKeys(jobName, failedRecordKeys));
|
||||||
.jobExecutionId(jobExecutionId)
|
|
||||||
.stepExecutionId(stepExecutionId)
|
|
||||||
.recordKey(recordKey)
|
|
||||||
.errorMessage(errorMessage)
|
|
||||||
.retryCount(retryCount)
|
|
||||||
.status("FAILED")
|
|
||||||
.build())
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
batchFailedRecordRepository.saveAll(records);
|
// 기존 레코드 업데이트 (실행 정보 갱신)
|
||||||
log.info("실패 레코드 {} 건 저장 완료 (job: {}, executionId: {})",
|
List<String> keysToUpdate = failedRecordKeys.stream()
|
||||||
records.size(), jobName, jobExecutionId);
|
.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) {
|
} catch (Exception e) {
|
||||||
log.error("실패 레코드 저장 실패: {}", e.getMessage());
|
log.error("실패 레코드 저장/업데이트 실패: {}", e.getMessage(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,6 +125,20 @@ public class BatchFailedRecordService {
|
|||||||
return resolved;
|
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로 처리합니다.
|
* 재수집 성공 건을 RESOLVED로 처리합니다.
|
||||||
* 원본 stepExecutionId로 범위를 제한하여 해당 Step의 실패 건만 RESOLVED 처리합니다.
|
* 원본 stepExecutionId로 범위를 제한하여 해당 Step의 실패 건만 RESOLVED 처리합니다.
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user