Compare commits
3 Commits
eccda289a2
...
597b7f26b9
| Author | SHA1 | Date | |
|---|---|---|---|
| 597b7f26b9 | |||
| e147847a2d | |||
| 8a72f920f1 |
@@ -22,6 +22,8 @@
|
|||||||
- 랭킹 스냅샷 lock key는 `lock:creator-ranking-snapshot-refresh`로 고정하고, lock 획득 실패 인스턴스는 정상 skip한다.
|
- 랭킹 스냅샷 lock key는 `lock:creator-ranking-snapshot-refresh`로 고정하고, lock 획득 실패 인스턴스는 정상 skip한다.
|
||||||
- 조회 API는 스냅샷 기반 응답을 기본으로 하며, 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 제한적 원천 데이터 fallback 집계를 시도할 수 있다.
|
- 조회 API는 스냅샷 기반 응답을 기본으로 하며, 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 제한적 원천 데이터 fallback 집계를 시도할 수 있다.
|
||||||
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 기존 최신 완료 주차 스냅샷 기준 응답을 유지한다.
|
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 기존 최신 완료 주차 스냅샷 기준 응답을 유지한다.
|
||||||
|
- 스냅샷 테이블이 완전히 비어 있는 cold-start fallback 성공 시 조회 API는 fallback 응답을 반환하고, 같은 집계 기간의 스냅샷 생성은 조회 서비스가 직접 저장하지 않고 `CreatorRankingSnapshotJobService`/`CreatorRankingSnapshotRefreshService` 책임으로 위임한다.
|
||||||
|
- cold-start fallback 스냅샷 생성 트리거는 운영 배포 직후 내부 테스트 등 초기 검증 보강책이며, 동일 집계 기간에 대해 한 번만 실행되도록 기간 기반 Redisson lock을 사용한다.
|
||||||
- 스냅샷 생성 직전 집계 시작/종료 시각을 포함한 job 이력을 생성하고, 스케줄 실행과 관리자 수동 생성 모두 성공/실패 상태를 기록한다.
|
- 스냅샷 생성 직전 집계 시작/종료 시각을 포함한 job 이력을 생성하고, 스케줄 실행과 관리자 수동 생성 모두 성공/실패 상태를 기록한다.
|
||||||
- 관리자는 날짜 범위를 직접 선택해 스냅샷 생성 job을 만들 수 있으며, 실패한 job은 관리자 전용 재시도 API로 대기 상태로 되돌려 재처리할 수 있어야 한다.
|
- 관리자는 날짜 범위를 직접 선택해 스냅샷 생성 job을 만들 수 있으며, 실패한 job은 관리자 전용 재시도 API로 대기 상태로 되돌려 재처리할 수 있어야 한다.
|
||||||
- 스냅샷은 현재 누적 저장하며, 보존 기간/정리 배치는 운영 데이터 규모 확인 후 별도 결정한다.
|
- 스냅샷은 현재 누적 저장하며, 보존 기간/정리 배치는 운영 데이터 규모 확인 후 별도 결정한다.
|
||||||
@@ -404,6 +406,45 @@
|
|||||||
- REFACTOR: 기존 Phase 7 로그와 이벤트명 충돌이 없도록 prefix를 정리한다.
|
- REFACTOR: 기존 Phase 7 로그와 이벤트명 충돌이 없도록 prefix를 정리한다.
|
||||||
- 기대 결과: 관리자 job과 cold-start fallback 상태를 운영 로그/메트릭으로 추적할 수 있다.
|
- 기대 결과: 관리자 job과 cold-start fallback 상태를 운영 로그/메트릭으로 추적할 수 있다.
|
||||||
|
|
||||||
|
### Phase 11: cold-start fallback 스냅샷 생성 트리거
|
||||||
|
|
||||||
|
- [x] **Task 11.1: cold-start fallback 전용 기간 기반 lock 실행 경계 추가**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt`
|
||||||
|
- RED: 스냅샷 테이블이 완전히 비어 있는 초기 상태에서 같은 KST 지난 주 기간에 대해 lock을 획득한 경우에만 refresh 책임을 실행하고, lock 획득 실패 시 refresh를 호출하지 않는 테스트를 작성한다. lock key는 집계 시작/종료 UTC 시각을 포함한 `lock:creator-ranking-snapshot-refresh:{start}:{end}` 형식으로 검증한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest`
|
||||||
|
- GREEN: `CreatorRankingSnapshotJobService`에 `ensureLastCompletedWeekSnapshotForColdStart()` 또는 동등한 메서드를 추가한다. 이 메서드는 `CreatorRankingPeriodPolicy`로 기간을 산출하고, Redisson lock을 `tryLock(0, -1, TimeUnit.SECONDS)`로 획득한 경우에만 기존 refresh service를 호출한다.
|
||||||
|
- REFACTOR: 조회 API가 직접 `creator_ranking_snapshot`을 저장하지 않도록 하고, lock 획득/해제와 refresh 위임 책임은 job service에 둔다. 스케줄러의 고정 lock key 정책은 유지하고, cold-start 전용 메서드에서만 기간 기반 lock key를 사용한다.
|
||||||
|
- 기대 결과: 운영 배포 직후 내부 테스트 등 초기 cold-start 상황에서 같은 기간 스냅샷 생성이 중복 실행되지 않는다.
|
||||||
|
|
||||||
|
- [x] **Task 11.2: fallback 성공 후 스냅샷 생성 책임 위임 연결**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt`
|
||||||
|
- RED: `getCreatorRankings()`가 최신 스냅샷 없음 + 스냅샷 테이블 완전 공백 상태에서 fallback 결과를 응답하면서 cold-start 스냅샷 생성 위임 메서드를 호출하는 테스트를 작성한다. 과거 스냅샷이 있거나 fallback 후보가 없으면 cold-start 생성 위임을 호출하지 않는 테스트도 작성한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest`
|
||||||
|
- GREEN: query service는 fallback 응답 조립 후 job service에 스냅샷 생성 책임을 위임한다. 위임 실패는 공개 API 응답을 깨지 않도록 catch 후 구조화 로그로 남기고, fallback 응답 스키마는 `showRankChange`와 `items` 그대로 유지한다.
|
||||||
|
- REFACTOR: fallback은 장기 실시간 랭킹 경로가 아니라 초기 상태 보강책임을 테스트명과 로그 이벤트명에 드러낸다.
|
||||||
|
- 기대 결과: 첫 내부 조회에서 fallback 응답을 내려주면서 이후 조회가 스냅샷 기반으로 전환될 수 있다.
|
||||||
|
|
||||||
|
- [x] **Task 11.3: cold-start 스냅샷 생성 트리거 회귀 검증**
|
||||||
|
- Files:
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/**`
|
||||||
|
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/**`
|
||||||
|
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt`
|
||||||
|
- Modify: `docs/20260608_크리에이터_랭킹/plan-task.md`
|
||||||
|
- RED: 테스트 작성 예외. `TDD 예외 사유`: 구현 완료 후 회귀 검증 task다.
|
||||||
|
- 대체 검증 방법:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest`
|
||||||
|
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'`
|
||||||
|
- `./gradlew ktlintCheck`
|
||||||
|
- GREEN: cold-start fallback, 스케줄러, 관리자 job, 차단 마스킹, CDN profile image 응답 테스트가 모두 통과해야 한다.
|
||||||
|
- REFACTOR: 검증 기록에 실행 명령, 목적, 결과를 누적한다.
|
||||||
|
- 기대 결과: cold-start 스냅샷 생성 보강이 기존 스케줄/관리자/조회 경로를 깨지 않는다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. PRD 요구사항 추적
|
## 2. PRD 요구사항 추적
|
||||||
@@ -414,8 +455,8 @@
|
|||||||
- Feature D: Task 1.2, Task 3.3, Task 4.1에서 채널 후원 캔/건수와 최상위 팬 Talk 집계를 검증한다.
|
- Feature D: Task 1.2, Task 3.3, Task 4.1에서 채널 후원 캔/건수와 최상위 팬 Talk 집계를 검증한다.
|
||||||
- Feature E: Task 1.2, Task 3.4, Task 4.1에서 최종 팔로우 수와 `createdAt`/`updatedAt` 기반 팔로우 증가 수를 검증한다.
|
- Feature E: Task 1.2, Task 3.4, Task 4.1에서 최종 팔로우 수와 `createdAt`/`updatedAt` 기반 팔로우 증가 수를 검증한다.
|
||||||
- Feature F: Task 1.2, Task 4.1, Task 5.1에서 raw value 최종 점수, 1점 미만 제외, 20위 동점 후보 저장, 동점 랜덤 조회를 검증한다.
|
- Feature F: Task 1.2, Task 4.1, Task 5.1에서 raw value 최종 점수, 1점 미만 제외, 20위 동점 후보 저장, 동점 랜덤 조회를 검증한다.
|
||||||
- Feature G: Task 5.1, Task 5.2에서 ranking 조회 결과와 차단 마스킹을 검증하고, Task 6.1, Task 6.2에서 홈 API endpoint, 응답 스키마, 인증/비인증 연결을 검증한다. Task 10.1, Task 10.2에서 스냅샷 테이블 완전 공백 상태의 제한적 fallback과 공개 응답 스키마 유지를 검증한다.
|
- Feature G: Task 5.1, Task 5.2에서 ranking 조회 결과와 차단 마스킹을 검증하고, Task 6.1, Task 6.2에서 홈 API endpoint, 응답 스키마, 인증/비인증 연결을 검증한다. Task 10.1, Task 10.2에서 스냅샷 테이블 완전 공백 상태의 제한적 fallback과 공개 응답 스키마 유지를 검증하고, Task 11.2에서 fallback 성공 후 응답을 깨지 않고 스냅샷 생성 책임을 위임하는 흐름을 검증한다.
|
||||||
- Feature H: Task 2.1, Task 2.2, Task 4.1, Task 4.2, Task 4.3에서 주간 스냅샷 저장, 스케줄, 클러스터 단일 실행 lock을 검증한다. Task 8.1, Task 8.2에서 스케줄 job 이력과 성공/실패 기록을 검증하고, Task 9.1, Task 9.2에서 관리자 날짜 범위 수동 생성과 실패 job 재시도 API를 검증한다.
|
- Feature H: Task 2.1, Task 2.2, Task 4.1, Task 4.2, Task 4.3에서 주간 스냅샷 저장, 스케줄, 클러스터 단일 실행 lock을 검증한다. Task 8.1, Task 8.2에서 스케줄 job 이력과 성공/실패 기록을 검증하고, Task 9.1, Task 9.2에서 관리자 날짜 범위 수동 생성과 실패 job 재시도 API를 검증한다. Task 11.1, Task 11.2에서 cold-start fallback 성공 후 기간 기반 lock으로 동일 기간 스냅샷 생성 중복을 방지하는 보강책을 검증한다.
|
||||||
- Feature I: Phase 5의 ranking 기능 본체는 `v2.ranking` 패키지 경계를 유지하고, Phase 6의 클라이언트 API 표면은 `v2.api.home` 하위에 둔다. Phase 8~10의 관리자/job/fallback 기능도 공개 API 응답 DTO를 변경하지 않는다.
|
- Feature I: Phase 5의 ranking 기능 본체는 `v2.ranking` 패키지 경계를 유지하고, Phase 6의 클라이언트 API 표면은 `v2.api.home` 하위에 둔다. Phase 8~10의 관리자/job/fallback 기능도 공개 API 응답 DTO를 변경하지 않는다.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -505,3 +546,34 @@
|
|||||||
- 2026-06-09: Phase 10 reviewer 수정 후 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 15s`를 확인했다.
|
- 2026-06-09: Phase 10 reviewer 수정 후 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 15s`를 확인했다.
|
||||||
- 2026-06-09: Phase 10 reviewer 수정 후 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 56s`를 확인했다.
|
- 2026-06-09: Phase 10 reviewer 수정 후 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 56s`를 확인했다.
|
||||||
- 2026-06-09: Phase 10 reviewer 수정 후 follow-up gate: 목표/보안 검증, 코드 품질 검토, QA focused 검증이 모두 `PASS` 판정을 반환했고 blocking issue가 없음을 확인했다.
|
- 2026-06-09: Phase 10 reviewer 수정 후 follow-up gate: 목표/보안 검증, 코드 품질 검토, QA focused 검증이 모두 `PASS` 판정을 반환했고 blocking issue가 없음을 확인했다.
|
||||||
|
|
||||||
|
- 2026-06-09: 사용자 후속 요청에 따라 cold-start fallback 성공 시 조회 API가 직접 스냅샷을 저장하지 않고 `CreatorRankingSnapshotJobService`/`CreatorRankingSnapshotRefreshService` 책임으로 위임하도록 PRD와 plan-task를 갱신했다. 동일 집계 기간 중복 생성을 막기 위해 기간 기반 Redisson lock key(`lock:creator-ranking-snapshot-refresh:{start}:{end}`)와 신규 Phase 11 Task 11.1~11.3을 추가했다. 문서 변경 검증으로 `rg -n "cold-start|ensureLastCompletedWeekSnapshotForColdStart|lock:creator-ranking-snapshot-refresh|Task 11|fallback 성공" docs/20260608_크리에이터_랭킹/prd.md docs/20260608_크리에이터_랭킹/plan-task.md` 및 `git diff -- docs/20260608_크리에이터_랭킹/prd.md docs/20260608_크리에이터_랭킹/plan-task.md`를 실행해 반영 범위를 확인했다.
|
||||||
|
|
||||||
|
- 2026-06-09: creator_ranking_snapshot 최신/직전 조회 기준 확인: `rg -n "max\(latest\.aggregation_end_at_utc\)|max\(previous\.aggregation_end_at_utc\)|order by .*id|findLatestSnapshots|findPreviousCompletedSnapshots" src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence` 및 repository 코드 확인 결과 최신/직전 조회는 `id`가 아니라 `aggregation_end_at_utc`의 max/previous max 기준이며, 기간 내 정렬은 `final_score desc`임을 확인했다.
|
||||||
|
- 2026-06-09: Phase 11 Task 11.1 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `RedissonClient` 생성자 인자와 `ensureLastCompletedWeekSnapshotForColdStart` 미구현으로 `compileTestKotlin` 실패를 확인했다.
|
||||||
|
- 2026-06-09: Phase 11 Task 11.1 GREEN 확인: 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 12s`를 확인했다.
|
||||||
|
- 2026-06-09: Phase 11 Task 11.2 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `snapshotJobService` 생성자 파라미터 미구현으로 `compileTestKotlin` 실패를 확인했다.
|
||||||
|
- 2026-06-09: Phase 11 Task 11.2 GREEN 확인: 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 16s`를 확인했다.
|
||||||
|
- 2026-06-09: Phase 11 focused 재검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `BUILD SUCCESSFUL in 2s`를 확인했다.
|
||||||
|
- 2026-06-09: Phase 11 ranking/API 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` 실행 결과 `BUILD SUCCESSFUL in 1m 9s`를 확인했다.
|
||||||
|
- 2026-06-09: Phase 11 포맷 검증: `./gradlew ktlintCheck`는 최초 main import 순서 위반으로 실패했고, import 정렬 후 재실행해 `BUILD SUCCESSFUL in 23s`를 확인했다.
|
||||||
|
|
||||||
|
- 2026-06-09: Phase 11 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 2m 52s`를 확인했다.
|
||||||
|
|
||||||
|
- 2026-06-09: Phase 11 reviewer gate 1차 Code Quality 검토: 스케줄러 고정 lock과 cold-start 기간 lock이 달라 동일 기간 refresh가 동시에 실행될 수 있어 `FAIL` 판정을 확인했다.
|
||||||
|
- 2026-06-09: Phase 11 reviewer 수정 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 스케줄 job이 cold-start와 같은 기간 lock을 사용하지 않아 신규 테스트 2건 실패를 확인했다.
|
||||||
|
- 2026-06-09: Phase 11 reviewer 수정 GREEN 확인: 스케줄 job refresh와 cold-start refresh가 공통 기간 기반 lock 경계를 사용하도록 수정한 뒤 동일 focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 56s`를 확인했다.
|
||||||
|
- 2026-06-09: Phase 11 reviewer 수정 후 focused 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `BUILD SUCCESSFUL in 9s`를 확인했다.
|
||||||
|
- 2026-06-09: Phase 11 reviewer 수정 후 ranking/API 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` 실행 결과 `BUILD SUCCESSFUL in 43s`를 확인했다.
|
||||||
|
- 2026-06-09: Phase 11 reviewer 수정 후 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 27s`를 확인했다.
|
||||||
|
- 2026-06-09: Phase 11 reviewer 수정 후 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 10s`를 확인했다.
|
||||||
|
|
||||||
|
- 2026-06-09: Phase 11 reviewer 2차 Code Quality 검토: 공통 period lock은 적용됐지만 transaction commit 전에 lock이 해제될 수 있어 `FAIL` 판정을 확인했다.
|
||||||
|
- 2026-06-09: Phase 11 reviewer 2차 수정 RED 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `TransactionTemplate`/transaction manager 생성자 인자 미구현으로 `compileTestKotlin` 실패를 확인했다.
|
||||||
|
- 2026-06-09: Phase 11 reviewer 2차 수정 GREEN 확인: `PlatformTransactionManager`로 `PROPAGATION_REQUIRES_NEW` `TransactionTemplate`을 내부 생성하고, period lock 안의 transaction commit 이후 unlock되도록 수정한 뒤 job service focused 테스트 재실행 결과 `BUILD SUCCESSFUL in 12s`를 확인했다.
|
||||||
|
- 2026-06-09: Phase 11 reviewer 2차 수정 후 focused 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest` 실행 결과 `BUILD SUCCESSFUL in 3s`를 확인했다.
|
||||||
|
- 2026-06-09: Phase 11 reviewer 2차 수정 후 ranking/API 범위 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.ranking.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` 실행 결과 `BUILD SUCCESSFUL in 45s`를 확인했다.
|
||||||
|
- 2026-06-09: Phase 11 reviewer 2차 수정 후 포맷 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL in 17s`를 확인했다.
|
||||||
|
- 2026-06-09: Phase 11 reviewer 2차 수정 후 전체 회귀 검증: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL in 1m 9s`를 확인했다.
|
||||||
|
|
||||||
|
- 2026-06-09: Phase 11 reviewer 2차 수정 후 Code Quality 재검토 결과 이전 blocking issue가 해소되어 `PASS` 판정을 확인했다.
|
||||||
|
|||||||
@@ -191,11 +191,15 @@
|
|||||||
- 조회 API는 스냅샷 기반 응답을 기본으로 하며, 공개 API 응답 스키마는 fallback 여부와 관계없이 변경하지 않는다.
|
- 조회 API는 스냅샷 기반 응답을 기본으로 하며, 공개 API 응답 스키마는 fallback 여부와 관계없이 변경하지 않는다.
|
||||||
- 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 조회 API가 제한적으로 원천 데이터 fallback 집계를 시도할 수 있다.
|
- 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 조회 API가 제한적으로 원천 데이터 fallback 집계를 시도할 수 있다.
|
||||||
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 최신 완료 주차 스냅샷 기준으로 응답한다.
|
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 최신 완료 주차 스냅샷 기준으로 응답한다.
|
||||||
|
- 스냅샷 테이블이 완전히 비어 있는 초기 상태에서 fallback 집계가 성공하면, 조회 API는 응답을 반환하면서 스냅샷 생성 책임을 `CreatorRankingSnapshotRefreshService`/`CreatorRankingSnapshotJobService` 쪽으로 위임해 같은 기간의 `creator_ranking_snapshot` 생성을 트리거한다.
|
||||||
|
- cold-start fallback에서 스냅샷 생성 트리거는 운영 배포 직후 내부 테스트 등 초기 검증 상황을 위한 보강책이며, 장기 실시간 집계 경로로 사용하지 않는다.
|
||||||
|
- cold-start fallback 스냅샷 생성은 동일 집계 기간에 대해 한 번만 실행되도록 기간 기반 Redisson lock을 사용하고, lock 획득 실패 시 다른 요청 또는 작업이 처리 중인 정상 skip으로 간주한다.
|
||||||
|
|
||||||
#### Edge Cases
|
#### Edge Cases
|
||||||
- 최종 점수 1점 이상인 랭킹 후보가 20명 미만이면 가능한 만큼만 내려준다.
|
- 최종 점수 1점 이상인 랭킹 후보가 20명 미만이면 가능한 만큼만 내려준다.
|
||||||
- 랭킹 계산 결과가 없으면 빈 배열로 성공 응답한다.
|
- 랭킹 계산 결과가 없으면 빈 배열로 성공 응답한다.
|
||||||
- 최신 완료 주차 스냅샷이 없고 스냅샷 테이블도 완전히 비어 있으면 제한적 원천 데이터 fallback 집계를 시도한 뒤 결과를 응답한다.
|
- 최신 완료 주차 스냅샷이 없고 스냅샷 테이블도 완전히 비어 있으면 제한적 원천 데이터 fallback 집계를 시도한 뒤 결과를 응답한다.
|
||||||
|
- fallback 성공 뒤 스냅샷 생성 트리거가 실패하더라도 공개 API 응답 스키마는 변경하지 않고, 실패는 로그/job 이력으로 추적한다.
|
||||||
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 기존 최신 완료 주차 스냅샷 기준 응답을 유지한다.
|
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 기존 최신 완료 주차 스냅샷 기준 응답을 유지한다.
|
||||||
- 직전 완료 주차 스냅샷이 없으면 `showRankChange`는 `false`로 내려주고, 각 item의 `rankChange`는 `null`, `isNew`는 `false`로 내려준다.
|
- 직전 완료 주차 스냅샷이 없으면 `showRankChange`는 `false`로 내려주고, 각 item의 `rankChange`는 `null`, `isNew`는 `false`로 내려준다.
|
||||||
|
|
||||||
@@ -223,9 +227,13 @@
|
|||||||
- 운영자는 관리자 전용 API를 통해 날짜 범위를 직접 선택해 스냅샷 생성 job을 생성할 수 있어야 한다.
|
- 운영자는 관리자 전용 API를 통해 날짜 범위를 직접 선택해 스냅샷 생성 job을 생성할 수 있어야 한다.
|
||||||
- 실패한 스냅샷 생성 job은 관리자 전용 재시도 API로 재시도할 수 있어야 하며, 기존 관리자 job 패턴과 같이 실패 상태 job을 대기 상태로 되돌려 worker가 다시 처리하도록 한다.
|
- 실패한 스냅샷 생성 job은 관리자 전용 재시도 API로 재시도할 수 있어야 하며, 기존 관리자 job 패턴과 같이 실패 상태 job을 대기 상태로 되돌려 worker가 다시 처리하도록 한다.
|
||||||
- 관리자 전용 job 목록 API는 날짜 범위, 실행 트리거, 상태, 실패 사유, 재시도 가능 여부를 확인할 수 있어야 한다.
|
- 관리자 전용 job 목록 API는 날짜 범위, 실행 트리거, 상태, 실패 사유, 재시도 가능 여부를 확인할 수 있어야 한다.
|
||||||
|
- cold-start fallback 성공 후 스냅샷 저장은 조회 서비스가 직접 DB에 쓰지 않고, 스냅샷 refresh 책임을 가진 job/service 경계로 위임한다.
|
||||||
|
- cold-start fallback 스냅샷 저장 트리거는 집계 기간을 포함한 Redisson lock key를 사용해 동일 기간 중복 생성을 방지한다. 예: `lock:creator-ranking-snapshot-refresh:{aggregationStartAtUtc}:{aggregationEndAtUtc}`.
|
||||||
|
- lock을 획득한 요청만 refresh job/service를 실행하고, lock을 획득하지 못한 요청은 이미 다른 실행자가 처리 중인 것으로 보고 fallback 응답만 반환한다.
|
||||||
|
|
||||||
#### Edge Cases
|
#### Edge Cases
|
||||||
- 최신 완료 주차 스냅샷이 없고 스냅샷 테이블이 완전히 비어 있으면 제한적 원천 데이터 fallback 집계를 시도하고, fallback 성공/실패를 장애 추적용 로그로 남긴다.
|
- 최신 완료 주차 스냅샷이 없고 스냅샷 테이블이 완전히 비어 있으면 제한적 원천 데이터 fallback 집계를 시도하고, fallback 성공/실패를 장애 추적용 로그로 남긴다.
|
||||||
|
- fallback 성공 후 스냅샷 저장 트리거는 실패하더라도 조회 응답을 실패시키지 않되, job 상태 또는 구조화 로그로 실패를 추적할 수 있어야 한다.
|
||||||
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 최신 완료 주차 스냅샷 기준 응답을 유지한다.
|
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 최신 완료 주차 스냅샷 기준 응답을 유지한다.
|
||||||
- 스냅샷 생성 중 일부 원천 집계가 실패하면 해당 주차 스냅샷 저장을 실패 처리하고 부분 결과를 공개하지 않는다.
|
- 스냅샷 생성 중 일부 원천 집계가 실패하면 해당 주차 스냅샷 저장을 실패 처리하고 부분 결과를 공개하지 않는다.
|
||||||
- Redisson lock 획득 실패는 다른 인스턴스가 같은 작업을 수행 중인 정상 skip으로 처리하고, 스냅샷 생성 실패로 집계하지 않는다.
|
- Redisson lock 획득 실패는 다른 인스턴스가 같은 작업을 수행 중인 정상 skip으로 처리하고, 스냅샷 생성 실패로 집계하지 않는다.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class CreatorRankingQueryService(
|
|||||||
private val snapshotPort: CreatorRankingSnapshotPort,
|
private val snapshotPort: CreatorRankingSnapshotPort,
|
||||||
private val blockPort: CreatorRankingBlockPort,
|
private val blockPort: CreatorRankingBlockPort,
|
||||||
private val aggregationPort: CreatorRankingAggregationPort,
|
private val aggregationPort: CreatorRankingAggregationPort,
|
||||||
|
private val snapshotJobService: CreatorRankingSnapshotJobService,
|
||||||
private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() },
|
private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() },
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val cloudFrontHost: String
|
private val cloudFrontHost: String
|
||||||
@@ -36,6 +37,9 @@ class CreatorRankingQueryService(
|
|||||||
if (latestItems.isEmpty()) {
|
if (latestItems.isEmpty()) {
|
||||||
if (snapshotPort.isSnapshotTableEmpty()) {
|
if (snapshotPort.isSnapshotTableEmpty()) {
|
||||||
val fallbackItems = aggregateColdStartFallback().toRankedItems()
|
val fallbackItems = aggregateColdStartFallback().toRankedItems()
|
||||||
|
if (fallbackItems.isNotEmpty()) {
|
||||||
|
delegateColdStartSnapshotRefresh()
|
||||||
|
}
|
||||||
val blockedCreatorIds = findBlockedCreatorIds(viewerMemberId = viewerMemberId, items = fallbackItems)
|
val blockedCreatorIds = findBlockedCreatorIds(viewerMemberId = viewerMemberId, items = fallbackItems)
|
||||||
return@runCatching QueryLogResult(
|
return@runCatching QueryLogResult(
|
||||||
result = CreatorRankingResult(
|
result = CreatorRankingResult(
|
||||||
@@ -127,6 +131,18 @@ class CreatorRankingQueryService(
|
|||||||
}.getOrThrow()
|
}.getOrThrow()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun delegateColdStartSnapshotRefresh() {
|
||||||
|
runCatching {
|
||||||
|
snapshotJobService.ensureLastCompletedWeekSnapshotForColdStart()
|
||||||
|
}.onFailure { ex ->
|
||||||
|
log.warn(
|
||||||
|
"event=creator_ranking_query_cold_start_snapshot_refresh_failure error={}",
|
||||||
|
ex.message,
|
||||||
|
ex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun List<CreatorRankingSnapshotRecord>.toRankedItems(): List<CreatorRankingItem> {
|
private fun List<CreatorRankingSnapshotRecord>.toRankedItems(): List<CreatorRankingItem> {
|
||||||
return groupBy { it.finalScore }
|
return groupBy { it.finalScore }
|
||||||
.toSortedMap(compareByDescending { it })
|
.toSortedMap(compareByDescending { it })
|
||||||
@@ -143,10 +159,14 @@ class CreatorRankingQueryService(
|
|||||||
isNew = false,
|
isNew = false,
|
||||||
creatorId = creatorId,
|
creatorId = creatorId,
|
||||||
nickname = nickname,
|
nickname = nickname,
|
||||||
profileImageUrl = profileImageUrl
|
profileImageUrl = profileImageUrl.toCdnUrl()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun String?.toCdnUrl(): String? {
|
||||||
|
return if (isNullOrBlank()) null else "$cloudFrontHost/$this"
|
||||||
|
}
|
||||||
|
|
||||||
private fun CreatorRankingSnapshotCandidate.toSnapshotRecord(utcRange: CreatorRankingUtcRange): CreatorRankingSnapshotRecord {
|
private fun CreatorRankingSnapshotCandidate.toSnapshotRecord(utcRange: CreatorRankingUtcRange): CreatorRankingSnapshotRecord {
|
||||||
val calculatedContentLiveScore = scorePolicy.calculateContentLiveScore(
|
val calculatedContentLiveScore = scorePolicy.calculateContentLiveScore(
|
||||||
liveCanAmount = liveCanAmount,
|
liveCanAmount = liveCanAmount,
|
||||||
|
|||||||
@@ -1,31 +1,49 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.ranking.application
|
package kr.co.vividnext.sodalive.v2.ranking.application
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicy
|
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicy
|
||||||
|
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingUtcRange
|
||||||
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobPort
|
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobPort
|
||||||
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobRecord
|
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobRecord
|
||||||
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus
|
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus
|
||||||
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobTrigger
|
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobTrigger
|
||||||
|
import org.redisson.api.RedissonClient
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager
|
||||||
|
import org.springframework.transaction.TransactionDefinition
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
class CreatorRankingSnapshotJobService(
|
class CreatorRankingSnapshotJobService(
|
||||||
private val refreshService: CreatorRankingSnapshotRefreshService,
|
private val refreshService: CreatorRankingSnapshotRefreshService,
|
||||||
private val jobPort: CreatorRankingSnapshotJobPort,
|
private val jobPort: CreatorRankingSnapshotJobPort,
|
||||||
|
private val redissonClient: RedissonClient,
|
||||||
|
transactionManager: PlatformTransactionManager,
|
||||||
private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() }
|
private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() }
|
||||||
) {
|
) {
|
||||||
private val log = LoggerFactory.getLogger(javaClass)
|
private val log = LoggerFactory.getLogger(javaClass)
|
||||||
private val periodPolicy = CreatorRankingPeriodPolicy()
|
private val periodPolicy = CreatorRankingPeriodPolicy()
|
||||||
|
private val transactionTemplate = TransactionTemplate(transactionManager).also { template ->
|
||||||
|
template.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
|
||||||
fun refreshLastCompletedWeekByScheduledJob() {
|
fun refreshLastCompletedWeekByScheduledJob() {
|
||||||
val now = nowProvider()
|
withLastCompletedWeekPeriodLock { now, utcRange ->
|
||||||
val period = periodPolicy.resolveLastCompletedWeek(now)
|
transactionTemplate.executeWithoutResult {
|
||||||
val utcRange = periodPolicy.toUtcRange(period)
|
refreshLastCompletedWeekByScheduledJob(now, utcRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshLastCompletedWeekByScheduledJob(
|
||||||
|
now: ZonedDateTime,
|
||||||
|
utcRange: CreatorRankingUtcRange
|
||||||
|
) {
|
||||||
val job = jobPort.save(
|
val job = jobPort.save(
|
||||||
CreatorRankingSnapshotJobRecord(
|
CreatorRankingSnapshotJobRecord(
|
||||||
aggregationStartAtUtc = utcRange.startInclusiveUtc,
|
aggregationStartAtUtc = utcRange.startInclusiveUtc,
|
||||||
@@ -89,6 +107,32 @@ class CreatorRankingSnapshotJobService(
|
|||||||
jobPort.markPending(jobId)
|
jobPort.markPending(jobId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun ensureLastCompletedWeekSnapshotForColdStart() {
|
||||||
|
withLastCompletedWeekPeriodLock { now, _ ->
|
||||||
|
transactionTemplate.executeWithoutResult {
|
||||||
|
refreshService.refreshLastCompletedWeek(now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun withLastCompletedWeekPeriodLock(action: (ZonedDateTime, CreatorRankingUtcRange) -> Unit) {
|
||||||
|
val now = nowProvider()
|
||||||
|
val period = periodPolicy.resolveLastCompletedWeek(now)
|
||||||
|
val utcRange = periodPolicy.toUtcRange(period)
|
||||||
|
val lockName = "lock:creator-ranking-snapshot-refresh:${utcRange.startInclusiveUtc}:${utcRange.endExclusiveUtc}"
|
||||||
|
val lock = redissonClient.getLock(lockName)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (lock.tryLock(0, -1, TimeUnit.SECONDS)) {
|
||||||
|
action(now, utcRange)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (lock.isHeldByCurrentThread) {
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun logJobStatusChanged(
|
private fun logJobStatusChanged(
|
||||||
job: CreatorRankingSnapshotJobRecord,
|
job: CreatorRankingSnapshotJobRecord,
|
||||||
status: CreatorRankingSnapshotJobStatus,
|
status: CreatorRankingSnapshotJobStatus,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import org.springframework.transaction.annotation.Transactional
|
|||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import javax.persistence.EntityManager
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://cdn.test"])
|
||||||
@AutoConfigureMockMvc
|
@AutoConfigureMockMvc
|
||||||
@Transactional
|
@Transactional
|
||||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
@@ -52,7 +52,7 @@ class CreatorRankingControllerTest @Autowired constructor(
|
|||||||
.andExpect(jsonPath("$.data.items[0].isNew").value(false))
|
.andExpect(jsonPath("$.data.items[0].isNew").value(false))
|
||||||
.andExpect(jsonPath("$.data.items[0].creatorId").value(1L))
|
.andExpect(jsonPath("$.data.items[0].creatorId").value(1L))
|
||||||
.andExpect(jsonPath("$.data.items[0].nickname").value("creator-one"))
|
.andExpect(jsonPath("$.data.items[0].nickname").value("creator-one"))
|
||||||
.andExpect(jsonPath("$.data.items[0].profileImageUrl").value("profile-one.png"))
|
.andExpect(jsonPath("$.data.items[0].profileImageUrl").value("https://cdn.test/profile-one.png"))
|
||||||
.andExpect(jsonPath("$.data.items[0].finalScore").doesNotExist())
|
.andExpect(jsonPath("$.data.items[0].finalScore").doesNotExist())
|
||||||
.andExpect(jsonPath("$.data.items[0].aggregationStartAtUtc").doesNotExist())
|
.andExpect(jsonPath("$.data.items[0].aggregationStartAtUtc").doesNotExist())
|
||||||
.andExpect(jsonPath("$.data.items[0].aggregationEndAtUtc").doesNotExist())
|
.andExpect(jsonPath("$.data.items[0].aggregationEndAtUtc").doesNotExist())
|
||||||
@@ -95,7 +95,7 @@ class CreatorRankingControllerTest @Autowired constructor(
|
|||||||
.andExpect(jsonPath("$.data.items[0].rank").value(1))
|
.andExpect(jsonPath("$.data.items[0].rank").value(1))
|
||||||
.andExpect(jsonPath("$.data.items[0].creatorId").value(0L))
|
.andExpect(jsonPath("$.data.items[0].creatorId").value(0L))
|
||||||
.andExpect(jsonPath("$.data.items[0].nickname").value(""))
|
.andExpect(jsonPath("$.data.items[0].nickname").value(""))
|
||||||
.andExpect(jsonPath("$.data.items[0].profileImageUrl").value("/profile/default-profile.png"))
|
.andExpect(jsonPath("$.data.items[0].profileImageUrl").value("https://cdn.test/profile/default-profile.png"))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveMember(seed: String, role: MemberRole): Member {
|
private fun saveMember(seed: String, role: MemberRole): Member {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import org.junit.jupiter.api.Assertions.assertTrue
|
|||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.jupiter.api.extension.ExtendWith
|
import org.junit.jupiter.api.extension.ExtendWith
|
||||||
|
import org.mockito.Mockito
|
||||||
import org.springframework.boot.test.system.CapturedOutput
|
import org.springframework.boot.test.system.CapturedOutput
|
||||||
import org.springframework.boot.test.system.OutputCaptureExtension
|
import org.springframework.boot.test.system.OutputCaptureExtension
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@@ -95,12 +96,17 @@ class CreatorRankingQueryServiceTest {
|
|||||||
fun shouldUseColdStartFallbackOnlyWhenSnapshotTableIsEmpty() {
|
fun shouldUseColdStartFallbackOnlyWhenSnapshotTableIsEmpty() {
|
||||||
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
|
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
|
||||||
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
|
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
|
||||||
|
val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
|
||||||
snapshotPort.snapshotTableEmpty = true
|
snapshotPort.snapshotTableEmpty = true
|
||||||
aggregationPort.candidates = listOf(
|
aggregationPort.candidates = listOf(
|
||||||
candidate(creatorId = 1L, liveCanAmount = 100),
|
candidate(creatorId = 1L, liveCanAmount = 100),
|
||||||
candidate(creatorId = 2L, liveCanAmount = 200)
|
candidate(creatorId = 2L, liveCanAmount = 200)
|
||||||
)
|
)
|
||||||
val service = service(snapshotPort = snapshotPort, aggregationPort = aggregationPort)
|
val service = service(
|
||||||
|
snapshotPort = snapshotPort,
|
||||||
|
aggregationPort = aggregationPort,
|
||||||
|
snapshotJobService = snapshotJobService
|
||||||
|
)
|
||||||
|
|
||||||
val result = service.getCreatorRankings(viewerMemberId = null)
|
val result = service.getCreatorRankings(viewerMemberId = null)
|
||||||
|
|
||||||
@@ -112,6 +118,27 @@ class CreatorRankingQueryServiceTest {
|
|||||||
assertEquals(1, aggregationPort.aggregateCallCount)
|
assertEquals(1, aggregationPort.aggregateCallCount)
|
||||||
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), aggregationPort.startInclusiveUtc)
|
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), aggregationPort.startInclusiveUtc)
|
||||||
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), aggregationPort.endExclusiveUtc)
|
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), aggregationPort.endExclusiveUtc)
|
||||||
|
Mockito.verify(snapshotJobService).ensureLastCompletedWeekSnapshotForColdStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("cold-start fallback 후보가 없으면 스냅샷 생성 위임을 호출하지 않는다")
|
||||||
|
fun shouldNotDelegateColdStartSnapshotRefreshWhenFallbackIsEmpty() {
|
||||||
|
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
|
||||||
|
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
|
||||||
|
val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
|
||||||
|
snapshotPort.snapshotTableEmpty = true
|
||||||
|
val service = service(
|
||||||
|
snapshotPort = snapshotPort,
|
||||||
|
aggregationPort = aggregationPort,
|
||||||
|
snapshotJobService = snapshotJobService
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = service.getCreatorRankings(viewerMemberId = null)
|
||||||
|
|
||||||
|
assertFalse(result.showRankChange)
|
||||||
|
assertTrue(result.items.isEmpty())
|
||||||
|
Mockito.verify(snapshotJobService, Mockito.never()).ensureLastCompletedWeekSnapshotForColdStart()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -119,15 +146,21 @@ class CreatorRankingQueryServiceTest {
|
|||||||
fun shouldNotUseColdStartFallbackWhenAnyHistoricalSnapshotExists() {
|
fun shouldNotUseColdStartFallbackWhenAnyHistoricalSnapshotExists() {
|
||||||
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
|
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
|
||||||
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
|
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
|
||||||
|
val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
|
||||||
snapshotPort.snapshotTableEmpty = false
|
snapshotPort.snapshotTableEmpty = false
|
||||||
aggregationPort.candidates = listOf(candidate(creatorId = 1L))
|
aggregationPort.candidates = listOf(candidate(creatorId = 1L))
|
||||||
val service = service(snapshotPort = snapshotPort, aggregationPort = aggregationPort)
|
val service = service(
|
||||||
|
snapshotPort = snapshotPort,
|
||||||
|
aggregationPort = aggregationPort,
|
||||||
|
snapshotJobService = snapshotJobService
|
||||||
|
)
|
||||||
|
|
||||||
val result = service.getCreatorRankings(viewerMemberId = null)
|
val result = service.getCreatorRankings(viewerMemberId = null)
|
||||||
|
|
||||||
assertFalse(result.showRankChange)
|
assertFalse(result.showRankChange)
|
||||||
assertTrue(result.items.isEmpty())
|
assertTrue(result.items.isEmpty())
|
||||||
assertEquals(0, aggregationPort.aggregateCallCount)
|
assertEquals(0, aggregationPort.aggregateCallCount)
|
||||||
|
Mockito.verify(snapshotJobService, Mockito.never()).ensureLastCompletedWeekSnapshotForColdStart()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -266,7 +299,7 @@ class CreatorRankingQueryServiceTest {
|
|||||||
assertNull(blockPort.memberId)
|
assertNull(blockPort.memberId)
|
||||||
assertEquals(1L, result.items.single().creatorId)
|
assertEquals(1L, result.items.single().creatorId)
|
||||||
assertEquals("creator-1", result.items.single().nickname)
|
assertEquals("creator-1", result.items.single().nickname)
|
||||||
assertEquals("profile-1.png", result.items.single().profileImageUrl)
|
assertEquals("https://cdn.test/profile-1.png", result.items.single().profileImageUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -339,15 +372,41 @@ class CreatorRankingQueryServiceTest {
|
|||||||
assertTrue(output.out.contains("error=fallback failed"))
|
assertTrue(output.out.contains("error=fallback failed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("cold-start 스냅샷 생성 위임 실패는 fallback 응답을 깨지 않고 로그로 남긴다")
|
||||||
|
fun shouldKeepFallbackResponseWhenColdStartSnapshotRefreshDelegationFails(output: CapturedOutput) {
|
||||||
|
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
|
||||||
|
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
|
||||||
|
val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
|
||||||
|
snapshotPort.snapshotTableEmpty = true
|
||||||
|
aggregationPort.candidates = listOf(candidate(creatorId = 1L))
|
||||||
|
Mockito.doThrow(IllegalStateException("cold-start refresh failed"))
|
||||||
|
.`when`(snapshotJobService).ensureLastCompletedWeekSnapshotForColdStart()
|
||||||
|
val service = service(
|
||||||
|
snapshotPort = snapshotPort,
|
||||||
|
aggregationPort = aggregationPort,
|
||||||
|
snapshotJobService = snapshotJobService
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = service.getCreatorRankings(viewerMemberId = null)
|
||||||
|
|
||||||
|
assertFalse(result.showRankChange)
|
||||||
|
assertEquals(listOf(1L), result.items.map { it.creatorId })
|
||||||
|
assertTrue(output.out.contains("event=creator_ranking_query_cold_start_snapshot_refresh_failure"))
|
||||||
|
assertTrue(output.out.contains("error=cold-start refresh failed"))
|
||||||
|
}
|
||||||
|
|
||||||
private fun service(
|
private fun service(
|
||||||
snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingQuerySnapshotPort(),
|
snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingQuerySnapshotPort(),
|
||||||
blockPort: CreatorRankingBlockPort = FakeCreatorRankingBlockPort(),
|
blockPort: CreatorRankingBlockPort = FakeCreatorRankingBlockPort(),
|
||||||
aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingQueryAggregationPort()
|
aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingQueryAggregationPort(),
|
||||||
|
snapshotJobService: CreatorRankingSnapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
|
||||||
): CreatorRankingQueryService {
|
): CreatorRankingQueryService {
|
||||||
return CreatorRankingQueryService(
|
return CreatorRankingQueryService(
|
||||||
snapshotPort = snapshotPort,
|
snapshotPort = snapshotPort,
|
||||||
blockPort = blockPort,
|
blockPort = blockPort,
|
||||||
aggregationPort = aggregationPort,
|
aggregationPort = aggregationPort,
|
||||||
|
snapshotJobService = snapshotJobService,
|
||||||
nowProvider = {
|
nowProvider = {
|
||||||
ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
|
ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,11 +11,17 @@ import org.junit.jupiter.api.DisplayName
|
|||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.jupiter.api.extension.ExtendWith
|
import org.junit.jupiter.api.extension.ExtendWith
|
||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
|
import org.redisson.api.RLock
|
||||||
|
import org.redisson.api.RedissonClient
|
||||||
import org.springframework.boot.test.system.CapturedOutput
|
import org.springframework.boot.test.system.CapturedOutput
|
||||||
import org.springframework.boot.test.system.OutputCaptureExtension
|
import org.springframework.boot.test.system.OutputCaptureExtension
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager
|
||||||
|
import org.springframework.transaction.TransactionDefinition
|
||||||
|
import org.springframework.transaction.support.SimpleTransactionStatus
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@ExtendWith(OutputCaptureExtension::class)
|
@ExtendWith(OutputCaptureExtension::class)
|
||||||
class CreatorRankingSnapshotJobServiceTest {
|
class CreatorRankingSnapshotJobServiceTest {
|
||||||
@@ -25,7 +31,8 @@ class CreatorRankingSnapshotJobServiceTest {
|
|||||||
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
||||||
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
||||||
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
|
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
|
||||||
val service = CreatorRankingSnapshotJobService(refreshService, jobPort) { now }
|
val redissonClient = periodLockRedissonClient(lockAcquired = true)
|
||||||
|
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
|
||||||
|
|
||||||
service.refreshLastCompletedWeekByScheduledJob()
|
service.refreshLastCompletedWeekByScheduledJob()
|
||||||
|
|
||||||
@@ -44,7 +51,8 @@ class CreatorRankingSnapshotJobServiceTest {
|
|||||||
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
||||||
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
||||||
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
|
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
|
||||||
val service = CreatorRankingSnapshotJobService(refreshService, jobPort) { now }
|
val redissonClient = periodLockRedissonClient(lockAcquired = true)
|
||||||
|
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
|
||||||
Mockito.doThrow(IllegalStateException("aggregate failed"))
|
Mockito.doThrow(IllegalStateException("aggregate failed"))
|
||||||
.`when`(refreshService).refreshLastCompletedWeek(now)
|
.`when`(refreshService).refreshLastCompletedWeek(now)
|
||||||
|
|
||||||
@@ -62,7 +70,7 @@ class CreatorRankingSnapshotJobServiceTest {
|
|||||||
fun shouldCreateManualPendingJobForRequestedPeriod() {
|
fun shouldCreateManualPendingJobForRequestedPeriod() {
|
||||||
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
||||||
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
||||||
val service = CreatorRankingSnapshotJobService(refreshService, jobPort)
|
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, unusedRedissonClient(), transactionManager())
|
||||||
val startAt = LocalDateTime.of(2026, 5, 31, 15, 0)
|
val startAt = LocalDateTime.of(2026, 5, 31, 15, 0)
|
||||||
val endAt = LocalDateTime.of(2026, 6, 7, 15, 0)
|
val endAt = LocalDateTime.of(2026, 6, 7, 15, 0)
|
||||||
|
|
||||||
@@ -82,7 +90,7 @@ class CreatorRankingSnapshotJobServiceTest {
|
|||||||
fun shouldFindJobsByRequestedPeriodAndStatuses() {
|
fun shouldFindJobsByRequestedPeriodAndStatuses() {
|
||||||
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
||||||
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
||||||
val service = CreatorRankingSnapshotJobService(refreshService, jobPort)
|
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, unusedRedissonClient(), transactionManager())
|
||||||
val startAt = LocalDateTime.of(2026, 5, 31, 15, 0)
|
val startAt = LocalDateTime.of(2026, 5, 31, 15, 0)
|
||||||
val endAt = LocalDateTime.of(2026, 6, 7, 15, 0)
|
val endAt = LocalDateTime.of(2026, 6, 7, 15, 0)
|
||||||
val failed = jobPort.save(
|
val failed = jobPort.save(
|
||||||
@@ -122,7 +130,7 @@ class CreatorRankingSnapshotJobServiceTest {
|
|||||||
fun shouldRetryOnlyFailedSnapshotJob() {
|
fun shouldRetryOnlyFailedSnapshotJob() {
|
||||||
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
||||||
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
||||||
val service = CreatorRankingSnapshotJobService(refreshService, jobPort)
|
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, unusedRedissonClient(), transactionManager())
|
||||||
val failed = jobPort.save(
|
val failed = jobPort.save(
|
||||||
CreatorRankingSnapshotJobRecord(
|
CreatorRankingSnapshotJobRecord(
|
||||||
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
|
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
|
||||||
@@ -166,7 +174,8 @@ class CreatorRankingSnapshotJobServiceTest {
|
|||||||
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
||||||
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
||||||
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
|
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
|
||||||
val service = CreatorRankingSnapshotJobService(refreshService, jobPort) { now }
|
val redissonClient = periodLockRedissonClient(lockAcquired = true)
|
||||||
|
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
|
||||||
|
|
||||||
service.refreshLastCompletedWeekByScheduledJob()
|
service.refreshLastCompletedWeekByScheduledJob()
|
||||||
|
|
||||||
@@ -183,7 +192,8 @@ class CreatorRankingSnapshotJobServiceTest {
|
|||||||
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
||||||
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
||||||
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
|
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
|
||||||
val service = CreatorRankingSnapshotJobService(refreshService, jobPort) { now }
|
val redissonClient = periodLockRedissonClient(lockAcquired = true)
|
||||||
|
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
|
||||||
Mockito.doThrow(IllegalStateException("aggregate failed"))
|
Mockito.doThrow(IllegalStateException("aggregate failed"))
|
||||||
.`when`(refreshService).refreshLastCompletedWeek(now)
|
.`when`(refreshService).refreshLastCompletedWeek(now)
|
||||||
|
|
||||||
@@ -197,6 +207,131 @@ class CreatorRankingSnapshotJobServiceTest {
|
|||||||
assertTrue(output.out.contains("status=FAILED"))
|
assertTrue(output.out.contains("status=FAILED"))
|
||||||
assertTrue(output.out.contains("error=aggregate failed"))
|
assertTrue(output.out.contains("error=aggregate failed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("스케줄 job refresh는 cold-start와 같은 기간 기반 lock 경계를 사용한다")
|
||||||
|
fun shouldUseSamePeriodLockForScheduledJobRefresh() {
|
||||||
|
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
||||||
|
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
||||||
|
val redissonClient = periodLockRedissonClient(lockAcquired = true)
|
||||||
|
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
|
||||||
|
val lockName = "lock:creator-ranking-snapshot-refresh:2026-05-31T15:00:2026-06-07T15:00"
|
||||||
|
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
|
||||||
|
|
||||||
|
service.refreshLastCompletedWeekByScheduledJob()
|
||||||
|
|
||||||
|
Mockito.verify(redissonClient).getLock(lockName)
|
||||||
|
Mockito.verify(refreshService).refreshLastCompletedWeek(now)
|
||||||
|
assertEquals(CreatorRankingSnapshotJobStatus.DONE, jobPort.jobs.single().status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("스케줄 job refresh는 기간 기반 lock 획득 실패 시 job 생성과 refresh를 건너뛴다")
|
||||||
|
fun shouldSkipScheduledJobRefreshWhenPeriodLockNotAcquired() {
|
||||||
|
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
||||||
|
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
||||||
|
val redissonClient = periodLockRedissonClient(lockAcquired = false)
|
||||||
|
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
|
||||||
|
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
|
||||||
|
|
||||||
|
service.refreshLastCompletedWeekByScheduledJob()
|
||||||
|
|
||||||
|
assertTrue(jobPort.jobs.isEmpty())
|
||||||
|
Mockito.verify(refreshService, Mockito.never()).refreshLastCompletedWeek(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("기간 기반 lock은 스냅샷 refresh transaction commit 이후 해제한다")
|
||||||
|
fun shouldUnlockPeriodLockAfterRefreshTransactionCommit() {
|
||||||
|
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
||||||
|
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
||||||
|
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||||
|
val lock = Mockito.mock(RLock::class.java)
|
||||||
|
val transactionManager = Mockito.mock(PlatformTransactionManager::class.java)
|
||||||
|
val transactionStatus = SimpleTransactionStatus()
|
||||||
|
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
|
||||||
|
val lockName = "lock:creator-ranking-snapshot-refresh:2026-05-31T15:00:2026-06-07T15:00"
|
||||||
|
Mockito.`when`(redissonClient.getLock(lockName)).thenReturn(lock)
|
||||||
|
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(true)
|
||||||
|
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
|
||||||
|
Mockito.`when`(transactionManager.getTransaction(Mockito.any(TransactionDefinition::class.java)))
|
||||||
|
.thenReturn(transactionStatus)
|
||||||
|
val service = CreatorRankingSnapshotJobService(
|
||||||
|
refreshService,
|
||||||
|
jobPort,
|
||||||
|
redissonClient,
|
||||||
|
transactionManager
|
||||||
|
) { now }
|
||||||
|
|
||||||
|
service.refreshLastCompletedWeekByScheduledJob()
|
||||||
|
|
||||||
|
val inOrder = Mockito.inOrder(transactionManager, lock)
|
||||||
|
inOrder.verify(transactionManager).commit(transactionStatus)
|
||||||
|
inOrder.verify(lock).unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("cold-start 스냅샷 생성은 기간 기반 lock 획득 시에만 refresh를 실행한다")
|
||||||
|
fun shouldRefreshColdStartSnapshotOnlyWhenPeriodLockAcquired() {
|
||||||
|
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
||||||
|
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
||||||
|
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||||
|
val lock = Mockito.mock(RLock::class.java)
|
||||||
|
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
|
||||||
|
val lockName = "lock:creator-ranking-snapshot-refresh:2026-05-31T15:00:2026-06-07T15:00"
|
||||||
|
Mockito.`when`(redissonClient.getLock(lockName)).thenReturn(lock)
|
||||||
|
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(true)
|
||||||
|
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
|
||||||
|
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
|
||||||
|
|
||||||
|
service.ensureLastCompletedWeekSnapshotForColdStart()
|
||||||
|
|
||||||
|
Mockito.verify(redissonClient).getLock(lockName)
|
||||||
|
Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS)
|
||||||
|
Mockito.verify(refreshService).refreshLastCompletedWeek(now)
|
||||||
|
Mockito.verify(lock).unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("cold-start 스냅샷 생성은 기간 기반 lock 획득 실패 시 refresh를 실행하지 않는다")
|
||||||
|
fun shouldSkipColdStartSnapshotRefreshWhenPeriodLockNotAcquired() {
|
||||||
|
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
||||||
|
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
||||||
|
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||||
|
val lock = Mockito.mock(RLock::class.java)
|
||||||
|
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
|
||||||
|
val lockName = "lock:creator-ranking-snapshot-refresh:2026-05-31T15:00:2026-06-07T15:00"
|
||||||
|
Mockito.`when`(redissonClient.getLock(lockName)).thenReturn(lock)
|
||||||
|
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(false)
|
||||||
|
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false)
|
||||||
|
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
|
||||||
|
|
||||||
|
service.ensureLastCompletedWeekSnapshotForColdStart()
|
||||||
|
|
||||||
|
Mockito.verify(redissonClient).getLock(lockName)
|
||||||
|
Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS)
|
||||||
|
Mockito.verify(refreshService, Mockito.never()).refreshLastCompletedWeek(now)
|
||||||
|
Mockito.verify(lock, Mockito.never()).unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unusedRedissonClient(): RedissonClient = Mockito.mock(RedissonClient::class.java)
|
||||||
|
|
||||||
|
private fun transactionManager(): PlatformTransactionManager {
|
||||||
|
val transactionManager = Mockito.mock(PlatformTransactionManager::class.java)
|
||||||
|
Mockito.`when`(transactionManager.getTransaction(Mockito.any(TransactionDefinition::class.java)))
|
||||||
|
.thenReturn(SimpleTransactionStatus())
|
||||||
|
return transactionManager
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun periodLockRedissonClient(lockAcquired: Boolean): RedissonClient {
|
||||||
|
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||||
|
val lock = Mockito.mock(RLock::class.java)
|
||||||
|
val lockName = "lock:creator-ranking-snapshot-refresh:2026-05-31T15:00:2026-06-07T15:00"
|
||||||
|
Mockito.`when`(redissonClient.getLock(lockName)).thenReturn(lock)
|
||||||
|
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(lockAcquired)
|
||||||
|
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(lockAcquired)
|
||||||
|
return redissonClient
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeCreatorRankingSnapshotJobPort : CreatorRankingSnapshotJobPort {
|
private class FakeCreatorRankingSnapshotJobPort : CreatorRankingSnapshotJobPort {
|
||||||
|
|||||||
Reference in New Issue
Block a user