Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.process.clash.application.github.service.GithubDailyStatsSyncService;
import com.process.clash.application.ranking.service.ZeroRankingDataInitService;
import com.process.clash.application.user.exp.service.GithubExpGrantService;
import java.util.concurrent.atomic.AtomicBoolean;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
Expand All @@ -17,28 +18,21 @@ public class GithubStatsSyncScheduler {
private final GithubDailyStatsSyncService syncService;
private final GithubExpGrantService githubExpGrantService;
private final ZeroRankingDataInitService zeroRankingDataInitService;
private final AtomicBoolean syncRunning = new AtomicBoolean(false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

현재 중복 실행 방지를 위해 AtomicBoolean을 사용하고 있습니다. 이 방식은 단일 JVM 인스턴스 내에서는 동시성을 안전하게 제어할 수 있지만, 만약 애플리케이션이 다중 인스턴스(Multi-instance / Clustered environment)로 배포되는 환경이라면 인스턴스 간의 중복 실행을 막을 수 없습니다.

향후 스케일아웃 환경을 고려한다면, DB를 활용한 분산 락(ShedLock 등)이나 Redis 기반의 분산 락을 도입하여 다중 서버 환경에서도 안전하게 중복 실행을 방지하는 것을 권장합니다. 현재 단일 인스턴스 환경이라면 이대로 사용해도 무방하지만, 아키텍처 확장성을 위해 참고해 주세요.


// 6시에는 365일 동기화가 작동하기에 30일 동기화는 6시를 제외한 매 시간에 작동하도록 설정했습니다.
@Async
@Scheduled(cron = "0 0 0-5,7-23 * * *", zone = "${github.sync.timezone:Asia/Seoul}")
public void runHourly30DaysSyncExceptMorningSix() {
log.info("GitHub 30일 동기화 스케줄러 시작.");
try {
syncAndGrant(syncService::syncRecent30Days);
} catch (Exception e) {
log.error("GitHub 30일 동기화 스케줄러 실패.", e);
}
runExclusive("GitHub 30일 동기화", () -> syncAndGrant(syncService::syncRecent30Days));
}

// 365일 동기화는 매일 오전 6시에만 작동. (이 시각에는 30일 동기화가 중복되기에 작동하지 않음)
@Async
@Scheduled(cron = "0 0 6 * * *", zone = "${github.sync.timezone:Asia/Seoul}")
public void runDaily365DaysSyncAtMorningSix() {
log.info("GitHub 365일 동기화 스케줄러 시작.");
try {
syncAndGrant(syncService::syncRecent365Days);
} catch (Exception e) {
log.error("GitHub 365일 동기화 스케줄러 실패.", e);
if (!runExclusive("GitHub 365일 동기화", () -> syncAndGrant(syncService::syncRecent365Days))) {
return;
}
try {
zeroRankingDataInitService.initZeroExpForToday();
Expand All @@ -51,4 +45,23 @@ private void syncAndGrant(Runnable syncAction) {
syncAction.run();
githubExpGrantService.grantForToday();
}

private boolean runExclusive(String jobName, Runnable action) {
if (!syncRunning.compareAndSet(false, true)) {
log.warn("{} 스케줄러를 건너뜁니다. 이전 GitHub 동기화가 아직 실행 중입니다.", jobName);
return false;
}

long startedAt = System.currentTimeMillis();
log.info("{} 스케줄러 시작.", jobName);
try {
action.run();
log.info("{} 스케줄러 완료. elapsedMs={}", jobName, System.currentTimeMillis() - startedAt);
} catch (Exception e) {
log.error("{} 스케줄러 실패. elapsedMs={}", jobName, System.currentTimeMillis() - startedAt, e);
} finally {
syncRunning.set(false);
}
return true;
}
Comment thread
chaeyn marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import com.process.clash.application.github.port.out.GithubStatsFetchPort;
import com.process.clash.application.github.port.out.GithubSyncTargetPort;
import com.process.clash.domain.github.entity.GitHubDailyStats;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
Expand All @@ -21,7 +20,6 @@
import java.util.concurrent.ExecutorService;

@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
public class GithubDailyStatsSyncService implements SyncGithubDailyStatsUseCase {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@
import com.process.clash.application.github.service.GithubDailyStatsSyncService;
import com.process.clash.application.ranking.service.ZeroRankingDataInitService;
import com.process.clash.application.user.exp.service.GithubExpGrantService;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

Expand Down Expand Up @@ -79,4 +86,52 @@ void runDaily365DaysSyncAtMorningSix_callsZeroExpInit() {

verify(zeroRankingDataInitService, times(1)).initZeroExpForToday();
}

@Test
@DisplayName("6시 GitHub 동기화가 실패해도 0 EXP 초기화를 호출한다")
void runDaily365DaysSyncAtMorningSix_callsZeroExpInitWhenSyncFails() {
doThrow(new RuntimeException("github sync failed")).when(syncService).syncRecent365Days();

scheduler.runDaily365DaysSyncAtMorningSix();

verify(zeroRankingDataInitService, times(1)).initZeroExpForToday();
}

@Test
@DisplayName("이전 GitHub 동기화가 실행 중이면 다음 GitHub 동기화를 건너뛴다")
void skipOverlappingGithubSyncJob() throws Exception {
CountDownLatch firstSyncStarted = new CountDownLatch(1);
CountDownLatch releaseFirstSync = new CountDownLatch(1);

doAnswer(invocation -> {
firstSyncStarted.countDown();
assertThat(releaseFirstSync.await(1, TimeUnit.SECONDS)).isTrue();
return null;
}).when(syncService).syncRecent30Days();

FutureTask<Void> firstJob = new FutureTask<>(() -> {
scheduler.runHourly30DaysSyncExceptMorningSix();
return null;
});
Thread firstJobThread = new Thread(firstJob, "github-sync-test");
firstJobThread.start();

assertThat(firstSyncStarted.await(1, TimeUnit.SECONDS)).isTrue();

scheduler.runDaily365DaysSyncAtMorningSix();

verify(syncService, never()).syncRecent365Days();
verify(zeroRankingDataInitService, never()).initZeroExpForToday();

releaseFirstSync.countDown();
firstJob.get(1, TimeUnit.SECONDS);

verify(githubExpGrantService).grantForToday();

scheduler.runDaily365DaysSyncAtMorningSix();

verify(syncService).syncRecent365Days();
verify(githubExpGrantService, times(2)).grantForToday();
verify(zeroRankingDataInitService).initZeroExpForToday();
}
}
Loading