Public Access
fix(library): sync again after a Steam ID change and keep library on empty result
An empty Steam answer (error, private profile, wrong ID) was stored as OK and soft-deleted the whole library. It is now FAILED, keeps the library and is retried after 15 minutes (app.library.failed-retry-minutes). The automatic sync now also runs when the Steam ID in the token differs from the stored one, and removes the games of the previous Steam account.
This commit is contained in:
1 parent
342ce1bea1
commit
b29cff76c8
4 files changed
+221
-6
No files matched your search
@@ -27,19 +27,22 @@ public class LibrarySyncService {
|
||||
@Value("${app.library.running-stale-minutes:5}")
|
||||
private long runningStaleMinutes;
|
||||
|
||||
@Value("${app.library.failed-retry-minutes:15}")
|
||||
private long failedRetryMinutes;
|
||||
|
||||
/**
|
||||
* Trigger sync only if data is older than TTL. Cheap gate check on caller thread,
|
||||
* actual sync runs async via worker.
|
||||
* Trigger sync only if data is older than TTL or was synced for another Steam ID.
|
||||
* Cheap gate check on caller thread, actual sync runs async via worker.
|
||||
*/
|
||||
public void syncIfStale(Long userId, String steamId) {
|
||||
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
|
||||
if (sync != null && !isStale(sync)) {
|
||||
return;
|
||||
}
|
||||
if (sync != null && isAlreadyRunning(sync)) {
|
||||
log.debug("Sync already running for user {}", userId);
|
||||
return;
|
||||
}
|
||||
if (sync != null && steamId.equals(sync.getSteamId()) && !isStale(sync)) {
|
||||
return;
|
||||
}
|
||||
worker.runSync(userId, steamId);
|
||||
}
|
||||
|
||||
@@ -62,7 +65,10 @@ public class LibrarySyncService {
|
||||
|
||||
private boolean isStale(UserSteamSync sync) {
|
||||
if (sync.getLastSyncedAt() == null) return true;
|
||||
return sync.getLastSyncedAt().isBefore(Instant.now().minus(Duration.ofHours(syncTtlHours)));
|
||||
Duration maxAge = sync.getLastSyncStatus() == UserSteamSync.Status.FAILED
|
||||
? Duration.ofMinutes(failedRetryMinutes)
|
||||
: Duration.ofHours(syncTtlHours);
|
||||
return sync.getLastSyncedAt().isBefore(Instant.now().minus(maxAge));
|
||||
}
|
||||
|
||||
private boolean isAlreadyRunning(UserSteamSync sync) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package core_service.modules.library.service;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
import core_service.modules.library.domain.UserGameId;
|
||||
import core_service.modules.library.domain.UserSteamSync;
|
||||
import core_service.modules.library.repo.SteamGameRepository;
|
||||
import core_service.modules.library.repo.UserGameRepository;
|
||||
import core_service.modules.library.repo.UserSteamSyncRepository;
|
||||
@@ -43,6 +44,9 @@ public class LibrarySyncWorker {
|
||||
@Transactional
|
||||
public void runSync(Long userId, String steamId) {
|
||||
Instant runningStaleBefore = Instant.now().minus(Duration.ofMinutes(runningStaleMinutes));
|
||||
String previousSteamId = userSteamSyncRepository.findById(userId)
|
||||
.map(UserSteamSync::getSteamId)
|
||||
.orElse(null);
|
||||
int claimed = userSteamSyncRepository.tryClaimRun(userId, steamId, runningStaleBefore);
|
||||
if (claimed == 0) {
|
||||
log.info("Sync skipped for user {} — another run already in flight", userId);
|
||||
@@ -52,8 +56,17 @@ public class LibrarySyncWorker {
|
||||
Instant syncStartedAt = Instant.now();
|
||||
Status finalStatus = Status.FAILED;
|
||||
try {
|
||||
if (previousSteamId != null && !previousSteamId.equals(steamId)) {
|
||||
// The old games belong to the previous Steam account. The upserts below restore the games this account also owns.
|
||||
userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now());
|
||||
}
|
||||
List<SteamOwnedGamesResponse.Game> games = steamApiService.getOwnedGames(steamId);
|
||||
log.info("Sync user={} fetched {} games from Steam", userId, games.size());
|
||||
if (games.isEmpty()) {
|
||||
// Steam errors and a private "Game details" setting also end here. A soft-delete would wipe the library.
|
||||
log.warn("Sync user={} got no games for SteamID {}", userId, steamId);
|
||||
return;
|
||||
}
|
||||
|
||||
for (SteamOwnedGamesResponse.Game g : games) {
|
||||
if (g.appid() == null) continue;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package core_service.modules.library.service;
|
||||
|
||||
import core_service.modules.library.domain.UserSteamSync;
|
||||
import core_service.modules.library.repo.UserSteamSyncRepository;
|
||||
import core_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class LibrarySyncServiceTest extends AbstractUnitTest {
|
||||
|
||||
private static final long USER_ID = 1L;
|
||||
private static final String STEAM_ID = "76561198005679788";
|
||||
|
||||
@Mock
|
||||
private UserSteamSyncRepository userSteamSyncRepository;
|
||||
|
||||
@Mock
|
||||
private LibrarySyncWorker worker;
|
||||
|
||||
@InjectMocks
|
||||
private LibrarySyncService syncService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(syncService, "syncTtlHours", 24L);
|
||||
ReflectionTestUtils.setField(syncService, "forceSyncCooldownSeconds", 60L);
|
||||
ReflectionTestUtils.setField(syncService, "runningStaleMinutes", 5L);
|
||||
ReflectionTestUtils.setField(syncService, "failedRetryMinutes", 15L);
|
||||
}
|
||||
|
||||
private void givenSync(String steamId, UserSteamSync.Status status, Duration age) {
|
||||
UserSteamSync sync = new UserSteamSync(USER_ID, steamId);
|
||||
sync.setLastSyncStatus(status);
|
||||
sync.setLastSyncedAt(Instant.now().minus(age));
|
||||
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(sync));
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncIfStale_NoSyncYet_Runs() {
|
||||
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.empty());
|
||||
|
||||
syncService.syncIfStale(USER_ID, STEAM_ID);
|
||||
|
||||
verify(worker).runSync(USER_ID, STEAM_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncIfStale_FreshOkSync_Skips() {
|
||||
givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofMinutes(10));
|
||||
|
||||
syncService.syncIfStale(USER_ID, STEAM_ID);
|
||||
|
||||
verify(worker, never()).runSync(anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncIfStale_FreshSyncForOtherSteamId_Runs() {
|
||||
givenSync("76561198005679790", UserSteamSync.Status.OK, Duration.ofMinutes(10));
|
||||
|
||||
syncService.syncIfStale(USER_ID, STEAM_ID);
|
||||
|
||||
verify(worker).runSync(USER_ID, STEAM_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncIfStale_FailedSyncAfterRetryInterval_Runs() {
|
||||
givenSync(STEAM_ID, UserSteamSync.Status.FAILED, Duration.ofMinutes(16));
|
||||
|
||||
syncService.syncIfStale(USER_ID, STEAM_ID);
|
||||
|
||||
verify(worker).runSync(USER_ID, STEAM_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncIfStale_FailedSyncWithinRetryInterval_Skips() {
|
||||
givenSync(STEAM_ID, UserSteamSync.Status.FAILED, Duration.ofMinutes(5));
|
||||
|
||||
syncService.syncIfStale(USER_ID, STEAM_ID);
|
||||
|
||||
verify(worker, never()).runSync(anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncIfStale_RunningForOtherSteamId_Skips() {
|
||||
givenSync("76561198005679790", UserSteamSync.Status.RUNNING, Duration.ofSeconds(10));
|
||||
|
||||
syncService.syncIfStale(USER_ID, STEAM_ID);
|
||||
|
||||
verify(worker, never()).runSync(anyLong(), anyString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package core_service.modules.library.service;
|
||||
|
||||
import core_service.modules.library.domain.UserSteamSync;
|
||||
import core_service.modules.library.repo.SteamGameRepository;
|
||||
import core_service.modules.library.repo.UserGameRepository;
|
||||
import core_service.modules.library.repo.UserSteamSyncRepository;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
|
||||
import core_service.modules.steam_adapter.service.SteamApiService;
|
||||
import core_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
|
||||
private static final long USER_ID = 1L;
|
||||
private static final String STEAM_ID = "76561198005679788";
|
||||
|
||||
@Mock
|
||||
private SteamApiService steamApiService;
|
||||
|
||||
@Mock
|
||||
private SteamGameRepository steamGameRepository;
|
||||
|
||||
@Mock
|
||||
private UserGameRepository userGameRepository;
|
||||
|
||||
@Mock
|
||||
private UserSteamSyncRepository userSteamSyncRepository;
|
||||
|
||||
@InjectMocks
|
||||
private LibrarySyncWorker worker;
|
||||
|
||||
@Test
|
||||
void runSync_EmptySteamResult_MarksFailedAndKeepsLibrary() {
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of());
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(userGameRepository, never()).softDeleteMissingSince(anyLong(), any(), any());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("FAILED"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_EmptyResultForNewSteamId_RemovesOldLibrary() {
|
||||
when(userSteamSyncRepository.findById(USER_ID))
|
||||
.thenReturn(Optional.of(new UserSteamSync(USER_ID, "76561198005679790")));
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of());
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("FAILED"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_SteamErrorForNewSteamId_RemovesOldLibrary() {
|
||||
when(userSteamSyncRepository.findById(USER_ID))
|
||||
.thenReturn(Optional.of(new UserSteamSync(USER_ID, "76561198005679790")));
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenThrow(new IllegalStateException("Steam down"));
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("FAILED"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_WithGames_MarksOk() {
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
|
||||
new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 0, "icon", true)));
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("OK"), any());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user