From 35b1db45e6894221c44566b1d2bcb968842a887d Mon Sep 17 00:00:00 2001 From: lobo Date: Sun, 4 Oct 2026 19:05:23 +0200 Subject: [PATCH] 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. --- .../library/api/LibraryController.java | 11 +- .../modules/library/domain/SyncTrigger.java | 3 +- .../modules/library/domain/UserSteamSync.java | 6 + .../library/repo/UserSteamSyncRepository.java | 35 +++- .../library/service/LibrarySyncService.java | 86 +++++--- src/main/resources/application.yml | 4 +- .../db/migration/V31__Login_Sync.sql | 4 + .../api/LibraryControllerSteamIdGoneTest.java | 5 +- .../repo/UserSteamSyncRepositoryTest.java | 193 ++++++++++++++++++ .../service/LibrarySyncServiceTest.java | 161 +++++++++++---- .../service/LibrarySyncWorkerTest.java | 9 + 11 files changed, 446 insertions(+), 71 deletions(-) create mode 100644 src/main/resources/db/migration/V31__Login_Sync.sql diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index bc0bf37..2822cd5 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -115,10 +115,15 @@ public class LibraryController { @GetMapping("/games-count") public ResponseEntity getGamesCount(@AuthenticationPrincipal UserPrincipal principal) { Long userId = principal.getIdAsLong(); - principal.steamIdText().ifPresentOrElse(steamId -> syncService.syncIfStale(userId, steamId), - () -> syncService.stopNightlySync(userId)); + boolean syncStarted = principal.steamIdText() + .map(steamId -> syncService.syncIfStale(userId, steamId)) + .orElseGet(() -> { + syncService.stopNightlySync(userId); + return false; + }); long count = queryService.countOwnedGames(userId); - return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null)); + return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null, + "syncStarted", syncStarted)); } @GetMapping("/games") diff --git a/src/main/java/core_service/modules/library/domain/SyncTrigger.java b/src/main/java/core_service/modules/library/domain/SyncTrigger.java index 4e461cf..2fa5870 100644 --- a/src/main/java/core_service/modules/library/domain/SyncTrigger.java +++ b/src/main/java/core_service/modules/library/domain/SyncTrigger.java @@ -2,5 +2,6 @@ package core_service.modules.library.domain; public enum SyncTrigger { MANUAL, - AUTO + AUTO, + LOGIN } diff --git a/src/main/java/core_service/modules/library/domain/UserSteamSync.java b/src/main/java/core_service/modules/library/domain/UserSteamSync.java index dbb3cf3..c980b41 100644 --- a/src/main/java/core_service/modules/library/domain/UserSteamSync.java +++ b/src/main/java/core_service/modules/library/domain/UserSteamSync.java @@ -45,6 +45,12 @@ public class UserSteamSync { @Column(name = "user_seen_at") private Instant userSeenAt; + @Column(name = "last_success_at") + private Instant lastSuccessAt; + + @Column(name = "login_sync_tried_at") + private Instant loginSyncTriedAt; + public UserSteamSync(Long userId, String steamId) { this.userId = userId; this.steamId = steamId; diff --git a/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java b/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java index ff153b6..a496346 100644 --- a/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java @@ -38,7 +38,9 @@ public interface UserSteamSyncRepository extends JpaRepository 'RUNNING' @@ -50,6 +52,34 @@ public interface UserSteamSyncRepository extends JpaRepository= :runningStaleBefore) + AND (user_steam_sync.steam_id <> EXCLUDED.steam_id + OR ((user_steam_sync.last_success_at IS NULL OR user_steam_sync.last_success_at < :successBefore) + AND (user_steam_sync.login_sync_tried_at IS NULL + OR user_steam_sync.login_sync_tried_at < :triedBefore))) + """, nativeQuery = true) + int tryStartLoginSync(@Param("userId") Long userId, @Param("steamId") String steamId, + @Param("now") Instant now, @Param("successBefore") Instant successBefore, + @Param("triedBefore") Instant triedBefore, + @Param("runningStaleBefore") Instant runningStaleBefore); + /** * @return 0 when another run took over the stale claim; that run's RUNNING stays */ @@ -57,7 +87,8 @@ public interface UserSteamSyncRepository extends JpaRepository isSyncActive(s, now)).orElse(false); } - worker.runSync(userId, steamId, SyncTrigger.AUTO); + worker.runSync(userId, steamId, SyncTrigger.LOGIN); + return true; } /** * Syncs every known Steam account shortly before midnight, so the playtime history gets the growth of each * day even when the user does not open the app. Accounts are synced one after the other on the scheduler - * thread; accounts without a user request in the last {@code nightly-active-days} are skipped. Afterwards every - * user's pile size of the day goes to backlog_snapshot, and old finished games get a date from their ending - * achievement. + * thread; accounts without a user request in the last {@code nightly-active-days}, or with an OK sync of the + * same day after 23:00, are skipped (the nightly sync books the playtime growth of the + * day, so an earlier sync must not replace it). Afterwards every user's pile size of the day goes to backlog_snapshot, and old + * finished games get a date from their ending achievement. */ @Scheduled(cron = "${app.library.nightly-sync-cron:0 55 23 * * *}", zone = "${app.library.zone:Europe/Berlin}") public void syncAllNightly() { - Instant activeSince = Instant.now().minus(Duration.ofDays(nightlyActiveDays)); - List accounts = userSteamSyncRepository.findByUserSeenAtAfter(activeSince); - log.info("Nightly sync for {} Steam accounts", accounts.size()); + Instant now = Instant.now(); + LocalDate today = LocalDate.now(zone); + List active = + userSteamSyncRepository.findByUserSeenAtAfter(now.minus(Duration.ofDays(nightlyActiveDays))); + List accounts = + active.stream().filter(s -> !syncedLateToday(s.getLastSuccessAt(), today)).toList(); + log.info("Nightly sync for {} of {} active Steam accounts", accounts.size(), active.size()); // Taken before the syncs: the loop can run past midnight. - LocalDate day = LocalDate.now(zone); + LocalDate day = today; try { accounts.forEach(sync -> worker.runSyncNow(sync.getUserId(), sync.getSteamId(), SyncTrigger.AUTO)); } finally { @@ -136,12 +155,29 @@ public class LibrarySyncService { } } - private boolean isStale(UserSteamSync sync) { - if (sync.getLastSyncedAt() == null) return true; - Duration maxAge = sync.getLastSyncStatus() == UserSteamSync.Status.FAILED - ? Duration.ofMinutes(failedRetryMinutes) - : Duration.ofHours(syncTtlHours); - return sync.getLastSyncedAt().isBefore(Instant.now().minus(maxAge)); + private boolean isLoginSyncDue(UserSteamSync sync, String steamId, Instant successBefore, Instant triedBefore) { + return !isAlreadyRunning(sync) + && (!steamId.equals(sync.getSteamId()) + || (isNullOrBefore(sync.getLastSuccessAt(), successBefore) + && isNullOrBefore(sync.getLoginSyncTriedAt(), triedBefore))); + } + + private boolean syncedLateToday(Instant lastSuccessAt, LocalDate today) { + if (lastSuccessAt == null) return false; + ZonedDateTime at = lastSuccessAt.atZone(zone); + return at.toLocalDate().equals(today) && !at.toLocalTime().isBefore(NIGHTLY_SKIP_FROM); + } + + // A sync of this visit is running, or the gate was won and the worker has not finished (or claimed) yet. + private boolean isSyncActive(UserSteamSync sync, Instant now) { + Instant tried = sync.getLoginSyncTriedAt(); + boolean justStarted = tried != null && tried.isAfter(now.minus(Duration.ofMinutes(runningStaleMinutes))) + && (sync.getLastSyncedAt() == null || sync.getLastSyncedAt().isBefore(tried)); + return isAlreadyRunning(sync) || justStarted; + } + + private static boolean isNullOrBefore(Instant value, Instant limit) { + return value == null || value.isBefore(limit); } private boolean isAlreadyRunning(UserSteamSync sync) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e34da7f..74dd582 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -56,7 +56,9 @@ app: enabled: true base-url: https://www.speedrun.com/api/v1 library: - sync-ttl-hours: 24 + sync-ttl-hours: 20 + # At most one sync try per user and hour on a visit, also after a failure. + login-retry-minutes: 60 force-sync-cooldown-seconds: 60 running-stale-minutes: 5 achievement-ttl-hours: 1 diff --git a/src/main/resources/db/migration/V31__Login_Sync.sql b/src/main/resources/db/migration/V31__Login_Sync.sql new file mode 100644 index 0000000..867bd53 --- /dev/null +++ b/src/main/resources/db/migration/V31__Login_Sync.sql @@ -0,0 +1,4 @@ +-- last_success_at: end of the last OK sync (any trigger). login_sync_tried_at: last start of a sync on a visit. +ALTER TABLE user_steam_sync ADD COLUMN last_success_at TIMESTAMPTZ; +ALTER TABLE user_steam_sync ADD COLUMN login_sync_tried_at TIMESTAMPTZ; +UPDATE user_steam_sync SET last_success_at = last_synced_at WHERE last_sync_status = 'OK'; diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerSteamIdGoneTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerSteamIdGoneTest.java index bf70656..f752293 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerSteamIdGoneTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerSteamIdGoneTest.java @@ -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)); diff --git a/src/test/java/core_service/modules/library/repo/UserSteamSyncRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserSteamSyncRepositoryTest.java index 89c4db1..7ca6cca 100644 --- a/src/test/java/core_service/modules/library/repo/UserSteamSyncRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserSteamSyncRepositoryTest.java @@ -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> 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 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); + } } diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java index 29f1d2f..9f89841 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java @@ -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)); diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java index 8a51507..8655274 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -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);