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:
lobo committed 2026-09-24 08:56:45 +02:00
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}") @Value("${app.library.running-stale-minutes:5}")
private long runningStaleMinutes; 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, * Trigger sync only if data is older than TTL or was synced for another Steam ID.
* actual sync runs async via worker. * Cheap gate check on caller thread, actual sync runs async via worker.
*/ */
public void syncIfStale(Long userId, String steamId) { public void syncIfStale(Long userId, String steamId) {
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null); UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
if (sync != null && !isStale(sync)) {
return;
}
if (sync != null && isAlreadyRunning(sync)) { if (sync != null && isAlreadyRunning(sync)) {
log.debug("Sync already running for user {}", userId); log.debug("Sync already running for user {}", userId);
return; return;
} }
if (sync != null && steamId.equals(sync.getSteamId()) && !isStale(sync)) {
return;
}
worker.runSync(userId, steamId); worker.runSync(userId, steamId);
} }
@@ -62,7 +65,10 @@ public class LibrarySyncService {
private boolean isStale(UserSteamSync sync) { private boolean isStale(UserSteamSync sync) {
if (sync.getLastSyncedAt() == null) return true; 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) { 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.SteamGame;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; 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.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserSteamSyncRepository; import core_service.modules.library.repo.UserSteamSyncRepository;
@@ -43,6 +44,9 @@ public class LibrarySyncWorker {
@Transactional @Transactional
public void runSync(Long userId, String steamId) { public void runSync(Long userId, String steamId) {
Instant runningStaleBefore = Instant.now().minus(Duration.ofMinutes(runningStaleMinutes)); 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); int claimed = userSteamSyncRepository.tryClaimRun(userId, steamId, runningStaleBefore);
if (claimed == 0) { if (claimed == 0) {
log.info("Sync skipped for user {} — another run already in flight", userId); log.info("Sync skipped for user {} — another run already in flight", userId);
@@ -52,8 +56,17 @@ public class LibrarySyncWorker {
Instant syncStartedAt = Instant.now(); Instant syncStartedAt = Instant.now();
Status finalStatus = Status.FAILED; Status finalStatus = Status.FAILED;
try { 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); List<SteamOwnedGamesResponse.Game> games = steamApiService.getOwnedGames(steamId);
log.info("Sync user={} fetched {} games from Steam", userId, games.size()); 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) { for (SteamOwnedGamesResponse.Game g : games) {
if (g.appid() == null) continue; 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());
}
}