feat(sync): start Steam sync on visit when data is older than 20 h

A visit starts a background sync when the last successful sync is
older than 20 h; one try per hour, one sync at a time. Migration V31
adds last_success_at and login_sync_tried_at. The nightly sync skips
only users with a successful sync today after 23:00. games-count
returns syncStarted.
This commit is contained in:
lobo committed 2026-10-04 19:05:23 +02:00
1 parent a8e208321f
commit 35b1db45e6
11 files changed
+446 -71

No files matched your search

@@ -16,6 +16,7 @@ import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
@@ -46,7 +47,9 @@ class LibraryControllerSteamIdGoneTest extends AbstractIntegrationTest {
@Test
void gamesCount_WithoutSteamIdClaim_StopsTheNightlySyncOfTheOldId() throws Exception {
mockMvc.perform(get("/api/library/games-count").cookie(loginWithoutSteamId())).andExpect(status().isOk());
mockMvc.perform(get("/api/library/games-count").cookie(loginWithoutSteamId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.syncStarted").value(false));
assertTrue(userSteamSyncRepository.findByUserSeenAtAfter(Instant.now().minus(Duration.ofDays(14))).stream()
.noneMatch(sync -> sync.getUserId() == USER_ID));
@@ -5,8 +5,17 @@ import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import static org.assertj.core.api.Assertions.assertThat;
@@ -68,4 +77,188 @@ class UserSteamSyncRepositoryTest extends AbstractPersistenceTest {
assertThat(claim(5L)).isZero();
}
private int tryLogin(long userId, String steamId) {
Instant now = Instant.now();
return repository.tryStartLoginSync(userId, steamId, now, now.minusSeconds(20 * 3600),
now.minusSeconds(3600), now.minusSeconds(300));
}
private void givenRow(long userId, String steamId, String status, String startedAgo, String successAgo,
String triedAgo) {
jdbc.update("insert into user_steam_sync (user_id, steam_id, last_sync_status, sync_started_at, "
+ "last_success_at, login_sync_tried_at) values (?, ?, ?, "
+ ago(startedAgo) + ", " + ago(successAgo) + ", " + ago(triedAgo) + ")",
userId, steamId, status);
}
private static String ago(String interval) {
return interval == null ? "null" : "now() - interval '" + interval + "'";
}
@Test
void tryStartLoginSync_NoRow_CreatesItWithTheTryStamp() {
assertThat(tryLogin(10L, STEAM_ID)).isEqualTo(1);
UserSteamSync row = repository.findById(10L).orElseThrow();
assertThat(row.getSteamId()).isEqualTo(STEAM_ID);
assertThat(row.getLoginSyncTriedAt()).isNotNull();
assertThat(row.getLastSyncStatus()).isNull();
}
@Test
void tryStartLoginSync_SuccessTenHoursAgo_IsRefused() {
givenRow(11L, STEAM_ID, "OK", null, "10 hours", null);
assertThat(tryLogin(11L, STEAM_ID)).isZero();
}
@Test
void tryStartLoginSync_SuccessTwentyOneHoursAgoNeverTried_Starts() {
givenRow(12L, STEAM_ID, "OK", null, "21 hours", null);
assertThat(tryLogin(12L, STEAM_ID)).isEqualTo(1);
}
@Test
void tryStartLoginSync_TriedThirtyMinutesAgo_IsRefused() {
givenRow(13L, STEAM_ID, "FAILED", null, "21 hours", "30 minutes");
assertThat(tryLogin(13L, STEAM_ID)).isZero();
}
@Test
void tryStartLoginSync_TriedSixtyOneMinutesAgo_Starts() {
givenRow(14L, STEAM_ID, "FAILED", null, "21 hours", "61 minutes");
assertThat(tryLogin(14L, STEAM_ID)).isEqualTo(1);
}
@Test
void tryStartLoginSync_SecondCallAfterAWinner_IsRefused() {
givenRow(15L, STEAM_ID, "OK", null, "21 hours", null);
assertThat(tryLogin(15L, STEAM_ID)).isEqualTo(1);
assertThat(tryLogin(15L, STEAM_ID)).isZero();
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void tryStartLoginSync_ParallelCallsOnAStaleRow_OnlyOneWins() throws Exception {
long userId = 16L;
jdbc.update("delete from user_steam_sync where user_id = ?", userId);
givenRow(userId, STEAM_ID, "OK", null, "21 hours", null);
int callers = 8;
ExecutorService pool = Executors.newFixedThreadPool(callers);
CountDownLatch go = new CountDownLatch(1);
try {
List<Future<Integer>> results = new ArrayList<>();
for (int i = 0; i < callers; i++) {
results.add(pool.submit(() -> {
go.await();
return tryLogin(userId, STEAM_ID);
}));
}
go.countDown();
int wins = 0;
for (Future<Integer> result : results) {
wins += result.get();
}
assertThat(wins).isEqualTo(1);
} finally {
pool.shutdownNow();
jdbc.update("delete from user_steam_sync where user_id = ?", userId);
}
}
@Test
void tryStartLoginSync_FreshRunning_IsRefused() {
givenRow(17L, STEAM_ID, "RUNNING", "10 seconds", "21 hours", null);
assertThat(tryLogin(17L, STEAM_ID)).isZero();
}
@Test
void tryStartLoginSync_RunningStartedTwentyMinutesAgo_Starts() {
givenRow(18L, STEAM_ID, "RUNNING", "20 minutes", "21 hours", null);
assertThat(tryLogin(18L, STEAM_ID)).isEqualTo(1);
}
@Test
void tryStartLoginSync_RunningWithoutStartTime_Starts() {
givenRow(19L, STEAM_ID, "RUNNING", null, "21 hours", null);
assertThat(tryLogin(19L, STEAM_ID)).isEqualTo(1);
}
@Test
void tryStartLoginSync_FreshSuccessButOtherSteamId_StartsAndKeepsTheOldId() {
givenRow(20L, "76561198005679790", "OK", null, "1 hour", null);
assertThat(tryLogin(20L, STEAM_ID)).isEqualTo(1);
assertThat(repository.findById(20L).orElseThrow().getSteamId()).isEqualTo("76561198005679790");
}
@Test
void tryStartLoginSync_FailedFirstSyncOfANewSteamId_RetriesAfterAnHour() {
givenRow(26L, "76561198005679790", "OK", null, "1 hour", null);
assertThat(tryLogin(26L, STEAM_ID)).isEqualTo(1);
Instant started = Instant.now();
repository.tryClaimRun(26L, STEAM_ID, started, started.minusSeconds(300));
repository.markFinished(26L, started, "FAILED", Instant.now());
Instant inAnHour = Instant.now().plusSeconds(61 * 60);
int started2 = repository.tryStartLoginSync(26L, STEAM_ID, inAnHour, inAnHour.minusSeconds(20 * 3600),
inAnHour.minusSeconds(3600), inAnHour.minusSeconds(300));
assertThat(started2).isEqualTo(1);
}
@Test
void tryStartLoginSync_DeletedUser_IsRefusedWithoutARow() {
jdbc.update("insert into deleted_user (user_id) values (21)");
assertThat(tryLogin(21L, STEAM_ID)).isZero();
assertThat(repository.findById(21L)).isEmpty();
}
@Test
void tryStartLoginSync_BlockedUser_IsRefusedWithoutARow() {
jdbc.update("insert into blocked_user (user_id) values (22)");
assertThat(tryLogin(22L, STEAM_ID)).isZero();
assertThat(repository.findById(22L)).isEmpty();
}
private Instant lastSuccessAt(long userId) {
Timestamp value = jdbc.queryForObject(
"select last_success_at from user_steam_sync where user_id = ?", Timestamp.class, userId);
return value == null ? null : value.toInstant();
}
@Test
void markFinished_Ok_SetsLastSuccessAt() {
Instant started = Instant.now();
repository.tryClaimRun(23L, STEAM_ID, started, started.minusSeconds(600));
repository.markFinished(23L, started, "OK", Instant.now());
assertThat(lastSuccessAt(23L)).isNotNull();
}
@Test
void markFinished_Failed_KeepsLastSuccessAt() {
Instant started = Instant.now();
repository.tryClaimRun(24L, STEAM_ID, started, started.minusSeconds(600));
repository.markFinished(24L, started, "OK", Instant.now().minusSeconds(3600));
Instant success = lastSuccessAt(24L);
Instant secondRun = Instant.now();
repository.tryClaimRun(24L, STEAM_ID, secondRun, secondRun.minusSeconds(600));
repository.markFinished(24L, secondRun, "FAILED", Instant.now());
assertThat(lastSuccessAt(24L)).isEqualTo(success);
}
}
@@ -19,7 +19,9 @@ import java.time.ZoneId;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
@@ -53,72 +55,107 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(syncService, "syncTtlHours", 24L);
ReflectionTestUtils.setField(syncService, "syncTtlHours", 20L);
ReflectionTestUtils.setField(syncService, "forceSyncCooldownSeconds", 60L);
ReflectionTestUtils.setField(syncService, "runningStaleMinutes", 5L);
ReflectionTestUtils.setField(syncService, "failedRetryMinutes", 15L);
ReflectionTestUtils.setField(syncService, "loginRetryMinutes", 60L);
ReflectionTestUtils.setField(syncService, "zone", ZoneId.of("Europe/Berlin"));
}
private void givenSync(String steamId, UserSteamSync.Status status, Duration age) {
private void givenSync(String steamId, UserSteamSync.Status status, Duration successAge, Duration triedAge) {
UserSteamSync sync = new UserSteamSync(USER_ID, steamId);
sync.setLastSyncStatus(status);
sync.setLastSyncedAt(Instant.now().minus(age));
sync.setSyncStartedAt(Instant.now().minus(age));
sync.setLastSyncedAt(Instant.now());
sync.setSyncStartedAt(Instant.now().minus(Duration.ofSeconds(10)));
sync.setLastSuccessAt(successAge == null ? null : Instant.now().minus(successAge));
sync.setLoginSyncTriedAt(triedAge == null ? null : Instant.now().minus(triedAge));
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(sync));
}
private void givenGate(int result) {
when(userSteamSyncRepository.tryStartLoginSync(eq(USER_ID), eq(STEAM_ID), any(), any(), any(), any()))
.thenReturn(result);
}
private void verifyGateNotCalled() {
verify(userSteamSyncRepository, never()).tryStartLoginSync(anyLong(), anyString(), any(), any(), any(), any());
}
@Test
void syncIfStale_NoSyncYet_Runs() {
void syncIfStale_NoSyncYet_AsksTheGateAndRuns() {
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.empty());
givenGate(1);
syncService.syncIfStale(USER_ID, STEAM_ID);
assertTrue(syncService.syncIfStale(USER_ID, STEAM_ID));
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.LOGIN);
}
@Test
void syncIfStale_FreshOkSync_Skips() {
givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofMinutes(10));
void syncIfStale_SuccessNineteenHoursAgo_SkipsWithoutAWrite() {
givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofHours(19), null);
syncService.syncIfStale(USER_ID, STEAM_ID);
assertFalse(syncService.syncIfStale(USER_ID, STEAM_ID));
verifyGateNotCalled();
verify(worker, never()).runSync(anyLong(), anyString(), any());
}
@Test
void syncIfStale_StaleButTriedThirtyMinutesAgo_SkipsWithoutAWrite() {
givenSync(STEAM_ID, UserSteamSync.Status.FAILED, Duration.ofHours(21), Duration.ofMinutes(30));
assertFalse(syncService.syncIfStale(USER_ID, STEAM_ID));
verifyGateNotCalled();
verify(worker, never()).runSync(anyLong(), anyString(), any());
}
@Test
void syncIfStale_StaleAndTriedLongAgo_GateWins_RunsALoginSync() {
givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofHours(21), Duration.ofMinutes(61));
givenGate(1);
assertTrue(syncService.syncIfStale(USER_ID, STEAM_ID));
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.LOGIN);
}
@Test
void syncIfStale_NeverSucceeded_GateWins_Runs() {
givenSync(STEAM_ID, UserSteamSync.Status.FAILED, null, null);
givenGate(1);
assertTrue(syncService.syncIfStale(USER_ID, STEAM_ID));
}
@Test
void syncIfStale_GateLoses_DoesNotRun() {
givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofHours(21), null);
givenGate(0);
assertFalse(syncService.syncIfStale(USER_ID, STEAM_ID));
verify(worker, never()).runSync(anyLong(), anyString(), any());
}
@Test
void syncIfStale_FreshSyncForOtherSteamId_Runs() {
givenSync("76561198005679790", UserSteamSync.Status.OK, Duration.ofMinutes(10));
void syncIfStale_FreshSuccessForOtherSteamId_AsksTheGate() {
givenSync("76561198005679790", UserSteamSync.Status.OK, Duration.ofHours(1), Duration.ofMinutes(5));
givenGate(1);
syncService.syncIfStale(USER_ID, STEAM_ID);
assertTrue(syncService.syncIfStale(USER_ID, STEAM_ID));
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.LOGIN);
}
@Test
void syncIfStale_FailedSyncAfterRetryInterval_Runs() {
givenSync(STEAM_ID, UserSteamSync.Status.FAILED, Duration.ofMinutes(16));
void syncIfStale_RunningForOtherSteamId_SkipsTheGateAndReportsTheRunningSync() {
givenSync("76561198005679790", UserSteamSync.Status.RUNNING, Duration.ofHours(30), null);
syncService.syncIfStale(USER_ID, STEAM_ID);
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
}
@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(), any());
}
@Test
void syncIfStale_RunningForOtherSteamId_Skips() {
givenSync("76561198005679790", UserSteamSync.Status.RUNNING, Duration.ofSeconds(10));
syncService.syncIfStale(USER_ID, STEAM_ID);
assertTrue(syncService.syncIfStale(USER_ID, STEAM_ID));
verifyGateNotCalled();
verify(worker, never()).runSync(anyLong(), anyString(), any());
}
@@ -136,6 +173,54 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
order.verify(finishedEstimateService).fillMissing();
}
@Test
void syncAllNightly_SkipsOnlyAccountsWithAnOkSyncOfTodayAfter23() {
ZoneId berlin = ZoneId.of("Europe/Berlin");
LocalDate today = LocalDate.now(berlin);
UserSteamSync late = new UserSteamSync(1L, "76561198000000001");
late.setLastSuccessAt(today.atTime(23, 0).atZone(berlin).toInstant());
UserSteamSync evening = new UserSteamSync(2L, "76561198005679790");
evening.setLastSuccessAt(today.atTime(22, 59).atZone(berlin).toInstant());
UserSteamSync yesterdayLate = new UserSteamSync(3L, "76561198005679791");
yesterdayLate.setLastSuccessAt(today.minusDays(1).atTime(23, 30).atZone(berlin).toInstant());
UserSteamSync never = new UserSteamSync(4L, "76561198005679792");
when(userSteamSyncRepository.findByUserSeenAtAfter(any()))
.thenReturn(List.of(late, evening, yesterdayLate, never));
syncService.syncAllNightly();
verify(worker, never()).runSyncNow(eq(1L), anyString(), any());
verify(worker).runSyncNow(2L, "76561198005679790", SyncTrigger.AUTO);
verify(worker).runSyncNow(3L, "76561198005679791", SyncTrigger.AUTO);
verify(worker).runSyncNow(4L, "76561198005679792", SyncTrigger.AUTO);
}
@Test
void syncIfStale_GateLost_OtherRequestJustStartedTheSync_ReportsItAsStarted() {
UserSteamSync before = new UserSteamSync(USER_ID, STEAM_ID);
before.setLastSuccessAt(Instant.now().minus(Duration.ofHours(21)));
UserSteamSync after = new UserSteamSync(USER_ID, STEAM_ID);
after.setLastSuccessAt(before.getLastSuccessAt());
after.setLoginSyncTriedAt(Instant.now());
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(before), Optional.of(after));
givenGate(0);
assertTrue(syncService.syncIfStale(USER_ID, STEAM_ID));
verify(worker, never()).runSync(anyLong(), anyString(), any());
}
@Test
void syncIfStale_TriedJustNowAndWorkerNotClaimedYet_ReportsItAsStarted() {
givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofHours(21), Duration.ofSeconds(1));
UserSteamSync sync = userSteamSyncRepository.findById(USER_ID).orElseThrow();
sync.setLastSyncedAt(Instant.now().minus(Duration.ofHours(21)));
assertTrue(syncService.syncIfStale(USER_ID, STEAM_ID));
verifyGateNotCalled();
}
@Test
void syncAllNightly_SnapshotFails_TheFinishDatesAreStillEstimated() {
when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of());
@@ -174,11 +259,11 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
void syncIfStale_MarksTheUserAsSeenAtMostHourly() {
UserSteamSync seenLongAgo = new UserSteamSync(USER_ID, STEAM_ID);
seenLongAgo.setLastSyncStatus(UserSteamSync.Status.OK);
seenLongAgo.setLastSyncedAt(Instant.now());
seenLongAgo.setLastSuccessAt(Instant.now());
seenLongAgo.setUserSeenAt(Instant.now().minus(Duration.ofDays(2)));
UserSteamSync seenJustNow = new UserSteamSync(USER_ID, STEAM_ID);
seenJustNow.setLastSyncStatus(UserSteamSync.Status.OK);
seenJustNow.setLastSyncedAt(Instant.now());
seenJustNow.setLastSuccessAt(Instant.now());
seenJustNow.setUserSeenAt(Instant.now());
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(seenLongAgo), Optional.of(seenJustNow));
@@ -192,6 +192,15 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("FAILED"), any());
}
@Test
void runSync_LoginTrigger_WritesTheResultWithThatTrigger() {
steamReturns(game(100, 0), null);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.LOGIN);
assertThat(replacedChanges(SyncTrigger.LOGIN, 1)).isEmpty();
}
@Test
void runSync_WithGames_MarksOk() {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);