Compare commits

..

3 Commits

7 changed files with 359 additions and 21 deletions

View File

@@ -22,6 +22,8 @@
- 랭킹 스냅샷 lock key는 `lock:creator-ranking-snapshot-refresh`로 고정하고, lock 획득 실패 인스턴스는 정상 skip한다.
- 조회 API는 스냅샷 기반 응답을 기본으로 하며, 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 제한적 원천 데이터 fallback 집계를 시도할 수 있다.
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 기존 최신 완료 주차 스냅샷 기준 응답을 유지한다.
- 스냅샷 테이블이 완전히 비어 있는 cold-start fallback 성공 시 조회 API는 fallback 응답을 반환하고, 같은 집계 기간의 스냅샷 생성은 조회 서비스가 직접 저장하지 않고 `CreatorRankingSnapshotJobService`/`CreatorRankingSnapshotRefreshService` 책임으로 위임한다.
- cold-start fallback 스냅샷 생성 트리거는 운영 배포 직후 내부 테스트 등 초기 검증 보강책이며, 동일 집계 기간에 대해 한 번만 실행되도록 기간 기반 Redisson lock을 사용한다.
- 스냅샷 생성 직전 집계 시작/종료 시각을 포함한 job 이력을 생성하고, 스케줄 실행과 관리자 수동 생성 모두 성공/실패 상태를 기록한다.
- 관리자는 날짜 범위를 직접 선택해 스냅샷 생성 job을 만들 수 있으며, 실패한 job은 관리자 전용 재시도 API로 대기 상태로 되돌려 재처리할 수 있어야 한다.
- 스냅샷은 현재 누적 저장하며, 보존 기간/정리 배치는 운영 데이터 규모 확인 후 별도 결정한다.
@@ -404,6 +406,45 @@
- REFACTOR: 기존 Phase 7 로그와 이벤트명 충돌이 없도록 prefix를 정리한다.
- 기대 결과: 관리자 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 요구사항 추적
@@ -414,8 +455,8 @@
- 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 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 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 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를 검증한다. 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를 변경하지 않는다.
---
@@ -505,3 +546,34 @@
- 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 수정 후 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` 판정을 확인했다.

View File

@@ -191,11 +191,15 @@
- 조회 API는 스냅샷 기반 응답을 기본으로 하며, 공개 API 응답 스키마는 fallback 여부와 관계없이 변경하지 않는다.
- 스냅샷 테이블이 완전히 비어 있는 초기 상태에서만 조회 API가 제한적으로 원천 데이터 fallback 집계를 시도할 수 있다.
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 최신 완료 주차 스냅샷 기준으로 응답한다.
- 스냅샷 테이블이 완전히 비어 있는 초기 상태에서 fallback 집계가 성공하면, 조회 API는 응답을 반환하면서 스냅샷 생성 책임을 `CreatorRankingSnapshotRefreshService`/`CreatorRankingSnapshotJobService` 쪽으로 위임해 같은 기간의 `creator_ranking_snapshot` 생성을 트리거한다.
- cold-start fallback에서 스냅샷 생성 트리거는 운영 배포 직후 내부 테스트 등 초기 검증 상황을 위한 보강책이며, 장기 실시간 집계 경로로 사용하지 않는다.
- cold-start fallback 스냅샷 생성은 동일 집계 기간에 대해 한 번만 실행되도록 기간 기반 Redisson lock을 사용하고, lock 획득 실패 시 다른 요청 또는 작업이 처리 중인 정상 skip으로 간주한다.
#### Edge Cases
- 최종 점수 1점 이상인 랭킹 후보가 20명 미만이면 가능한 만큼만 내려준다.
- 랭킹 계산 결과가 없으면 빈 배열로 성공 응답한다.
- 최신 완료 주차 스냅샷이 없고 스냅샷 테이블도 완전히 비어 있으면 제한적 원천 데이터 fallback 집계를 시도한 뒤 결과를 응답한다.
- fallback 성공 뒤 스냅샷 생성 트리거가 실패하더라도 공개 API 응답 스키마는 변경하지 않고, 실패는 로그/job 이력으로 추적한다.
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 기존 최신 완료 주차 스냅샷 기준 응답을 유지한다.
- 직전 완료 주차 스냅샷이 없으면 `showRankChange``false`로 내려주고, 각 item의 `rankChange``null`, `isNew``false`로 내려준다.
@@ -223,9 +227,13 @@
- 운영자는 관리자 전용 API를 통해 날짜 범위를 직접 선택해 스냅샷 생성 job을 생성할 수 있어야 한다.
- 실패한 스냅샷 생성 job은 관리자 전용 재시도 API로 재시도할 수 있어야 하며, 기존 관리자 job 패턴과 같이 실패 상태 job을 대기 상태로 되돌려 worker가 다시 처리하도록 한다.
- 관리자 전용 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
- 최신 완료 주차 스냅샷이 없고 스냅샷 테이블이 완전히 비어 있으면 제한적 원천 데이터 fallback 집계를 시도하고, fallback 성공/실패를 장애 추적용 로그로 남긴다.
- fallback 성공 후 스냅샷 저장 트리거는 실패하더라도 조회 응답을 실패시키지 않되, job 상태 또는 구조화 로그로 실패를 추적할 수 있어야 한다.
- 스냅샷 테이블에 과거 스냅샷이 하나라도 있으면 원천 데이터 fallback을 시도하지 않고 최신 완료 주차 스냅샷 기준 응답을 유지한다.
- 스냅샷 생성 중 일부 원천 집계가 실패하면 해당 주차 스냅샷 저장을 실패 처리하고 부분 결과를 공개하지 않는다.
- Redisson lock 획득 실패는 다른 인스턴스가 같은 작업을 수행 중인 정상 skip으로 처리하고, 스냅샷 생성 실패로 집계하지 않는다.

View File

@@ -20,6 +20,7 @@ class CreatorRankingQueryService(
private val snapshotPort: CreatorRankingSnapshotPort,
private val blockPort: CreatorRankingBlockPort,
private val aggregationPort: CreatorRankingAggregationPort,
private val snapshotJobService: CreatorRankingSnapshotJobService,
private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() },
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
@@ -36,6 +37,9 @@ class CreatorRankingQueryService(
if (latestItems.isEmpty()) {
if (snapshotPort.isSnapshotTableEmpty()) {
val fallbackItems = aggregateColdStartFallback().toRankedItems()
if (fallbackItems.isNotEmpty()) {
delegateColdStartSnapshotRefresh()
}
val blockedCreatorIds = findBlockedCreatorIds(viewerMemberId = viewerMemberId, items = fallbackItems)
return@runCatching QueryLogResult(
result = CreatorRankingResult(
@@ -127,6 +131,18 @@ class CreatorRankingQueryService(
}.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> {
return groupBy { it.finalScore }
.toSortedMap(compareByDescending { it })
@@ -143,10 +159,14 @@ class CreatorRankingQueryService(
isNew = false,
creatorId = creatorId,
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 {
val calculatedContentLiveScore = scorePolicy.calculateContentLiveScore(
liveCanAmount = liveCanAmount,

View File

@@ -1,31 +1,49 @@
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.CreatorRankingUtcRange
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.CreatorRankingSnapshotJobStatus
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobTrigger
import org.redisson.api.RedissonClient
import org.slf4j.LoggerFactory
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.support.TransactionTemplate
import java.time.LocalDateTime
import java.time.ZonedDateTime
import java.util.concurrent.TimeUnit
@Service
@Transactional(readOnly = true)
class CreatorRankingSnapshotJobService(
private val refreshService: CreatorRankingSnapshotRefreshService,
private val jobPort: CreatorRankingSnapshotJobPort,
private val redissonClient: RedissonClient,
transactionManager: PlatformTransactionManager,
private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() }
) {
private val log = LoggerFactory.getLogger(javaClass)
private val periodPolicy = CreatorRankingPeriodPolicy()
private val transactionTemplate = TransactionTemplate(transactionManager).also { template ->
template.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
}
@Transactional
fun refreshLastCompletedWeekByScheduledJob() {
val now = nowProvider()
val period = periodPolicy.resolveLastCompletedWeek(now)
val utcRange = periodPolicy.toUtcRange(period)
withLastCompletedWeekPeriodLock { now, utcRange ->
transactionTemplate.executeWithoutResult {
refreshLastCompletedWeekByScheduledJob(now, utcRange)
}
}
}
private fun refreshLastCompletedWeekByScheduledJob(
now: ZonedDateTime,
utcRange: CreatorRankingUtcRange
) {
val job = jobPort.save(
CreatorRankingSnapshotJobRecord(
aggregationStartAtUtc = utcRange.startInclusiveUtc,
@@ -89,6 +107,32 @@ class CreatorRankingSnapshotJobService(
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(
job: CreatorRankingSnapshotJobRecord,
status: CreatorRankingSnapshotJobStatus,

View File

@@ -22,7 +22,7 @@ import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import javax.persistence.EntityManager
@SpringBootTest
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://cdn.test"])
@AutoConfigureMockMvc
@Transactional
@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].creatorId").value(1L))
.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].aggregationStartAtUtc").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].creatorId").value(0L))
.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 {

View File

@@ -14,6 +14,7 @@ import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mockito
import org.springframework.boot.test.system.CapturedOutput
import org.springframework.boot.test.system.OutputCaptureExtension
import java.time.LocalDateTime
@@ -95,12 +96,17 @@ class CreatorRankingQueryServiceTest {
fun shouldUseColdStartFallbackOnlyWhenSnapshotTableIsEmpty() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
snapshotPort.snapshotTableEmpty = true
aggregationPort.candidates = listOf(
candidate(creatorId = 1L, liveCanAmount = 100),
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)
@@ -112,6 +118,27 @@ class CreatorRankingQueryServiceTest {
assertEquals(1, aggregationPort.aggregateCallCount)
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), aggregationPort.startInclusiveUtc)
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
@@ -119,15 +146,21 @@ class CreatorRankingQueryServiceTest {
fun shouldNotUseColdStartFallbackWhenAnyHistoricalSnapshotExists() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
snapshotPort.snapshotTableEmpty = false
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)
assertFalse(result.showRankChange)
assertTrue(result.items.isEmpty())
assertEquals(0, aggregationPort.aggregateCallCount)
Mockito.verify(snapshotJobService, Mockito.never()).ensureLastCompletedWeekSnapshotForColdStart()
}
@Test
@@ -266,7 +299,7 @@ class CreatorRankingQueryServiceTest {
assertNull(blockPort.memberId)
assertEquals(1L, result.items.single().creatorId)
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
@@ -339,15 +372,41 @@ class CreatorRankingQueryServiceTest {
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(
snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingQuerySnapshotPort(),
blockPort: CreatorRankingBlockPort = FakeCreatorRankingBlockPort(),
aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingQueryAggregationPort()
aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingQueryAggregationPort(),
snapshotJobService: CreatorRankingSnapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
): CreatorRankingQueryService {
return CreatorRankingQueryService(
snapshotPort = snapshotPort,
blockPort = blockPort,
aggregationPort = aggregationPort,
snapshotJobService = snapshotJobService,
nowProvider = {
ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
},

View File

@@ -11,11 +11,17 @@ import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
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.OutputCaptureExtension
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.support.SimpleTransactionStatus
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
import java.util.concurrent.TimeUnit
@ExtendWith(OutputCaptureExtension::class)
class CreatorRankingSnapshotJobServiceTest {
@@ -25,7 +31,8 @@ class CreatorRankingSnapshotJobServiceTest {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort()
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()
@@ -44,7 +51,8 @@ class CreatorRankingSnapshotJobServiceTest {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort()
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"))
.`when`(refreshService).refreshLastCompletedWeek(now)
@@ -62,7 +70,7 @@ class CreatorRankingSnapshotJobServiceTest {
fun shouldCreateManualPendingJobForRequestedPeriod() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
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 endAt = LocalDateTime.of(2026, 6, 7, 15, 0)
@@ -82,7 +90,7 @@ class CreatorRankingSnapshotJobServiceTest {
fun shouldFindJobsByRequestedPeriodAndStatuses() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
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 endAt = LocalDateTime.of(2026, 6, 7, 15, 0)
val failed = jobPort.save(
@@ -122,7 +130,7 @@ class CreatorRankingSnapshotJobServiceTest {
fun shouldRetryOnlyFailedSnapshotJob() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort()
val service = CreatorRankingSnapshotJobService(refreshService, jobPort)
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, unusedRedissonClient(), transactionManager())
val failed = jobPort.save(
CreatorRankingSnapshotJobRecord(
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
@@ -166,7 +174,8 @@ class CreatorRankingSnapshotJobServiceTest {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort()
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()
@@ -183,7 +192,8 @@ class CreatorRankingSnapshotJobServiceTest {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort()
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"))
.`when`(refreshService).refreshLastCompletedWeek(now)
@@ -197,6 +207,131 @@ class CreatorRankingSnapshotJobServiceTest {
assertTrue(output.out.contains("status=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 {