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

@@ -115,10 +115,15 @@ public class LibraryController {
@GetMapping("/games-count") @GetMapping("/games-count")
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) { public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
Long userId = principal.getIdAsLong(); Long userId = principal.getIdAsLong();
principal.steamIdText().ifPresentOrElse(steamId -> syncService.syncIfStale(userId, steamId), boolean syncStarted = principal.steamIdText()
() -> syncService.stopNightlySync(userId)); .map(steamId -> syncService.syncIfStale(userId, steamId))
.orElseGet(() -> {
syncService.stopNightlySync(userId);
return false;
});
long count = queryService.countOwnedGames(userId); 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") @GetMapping("/games")
@@ -2,5 +2,6 @@ package core_service.modules.library.domain;
public enum SyncTrigger { public enum SyncTrigger {
MANUAL, MANUAL,
AUTO AUTO,
LOGIN
} }
@@ -45,6 +45,12 @@ public class UserSteamSync {
@Column(name = "user_seen_at") @Column(name = "user_seen_at")
private Instant userSeenAt; 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) { public UserSteamSync(Long userId, String steamId) {
this.userId = userId; this.userId = userId;
this.steamId = steamId; this.steamId = steamId;
@@ -38,7 +38,9 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
WHERE NOT EXISTS (SELECT 1 FROM deleted_user WHERE user_id = :userId) WHERE NOT EXISTS (SELECT 1 FROM deleted_user WHERE user_id = :userId)
AND NOT EXISTS (SELECT 1 FROM blocked_user WHERE user_id = :userId) AND NOT EXISTS (SELECT 1 FROM blocked_user WHERE user_id = :userId)
ON CONFLICT (user_id) DO UPDATE ON CONFLICT (user_id) DO UPDATE
SET steam_id = EXCLUDED.steam_id, SET last_success_at = CASE WHEN user_steam_sync.steam_id IS DISTINCT FROM EXCLUDED.steam_id
THEN NULL ELSE user_steam_sync.last_success_at END,
steam_id = EXCLUDED.steam_id,
last_sync_status = 'RUNNING', last_sync_status = 'RUNNING',
sync_started_at = EXCLUDED.sync_started_at sync_started_at = EXCLUDED.sync_started_at
WHERE user_steam_sync.last_sync_status <> 'RUNNING' WHERE user_steam_sync.last_sync_status <> 'RUNNING'
@@ -50,6 +52,34 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
@Param("startedAt") Instant startedAt, @Param("startedAt") Instant startedAt,
@Param("runningStaleBefore") Instant runningStaleBefore); @Param("runningStaleBefore") Instant runningStaleBefore);
/**
* The gate of the sync on a visit: stamps login_sync_tried_at and returns 1 only when a sync is due, so of many
* parallel requests one wins. Due: no fresh RUNNING run, and either another Steam ID, or no OK sync since
* successBefore and no try since triedBefore. 0 for a deleted or blocked user. A new row gets only the Steam ID.
* steam_id is not updated: the worker claim compares the old ID to detect an account change.
*/
@Modifying
@Transactional
@Query(value = """
INSERT INTO user_steam_sync (user_id, steam_id, login_sync_tried_at)
SELECT :userId, :steamId, :now
WHERE NOT EXISTS (SELECT 1 FROM deleted_user WHERE user_id = :userId)
AND NOT EXISTS (SELECT 1 FROM blocked_user WHERE user_id = :userId)
ON CONFLICT (user_id) DO UPDATE
SET login_sync_tried_at = EXCLUDED.login_sync_tried_at
WHERE NOT (user_steam_sync.last_sync_status = 'RUNNING'
AND user_steam_sync.sync_started_at IS NOT NULL
AND user_steam_sync.sync_started_at >= :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 * @return 0 when another run took over the stale claim; that run's RUNNING stays
*/ */
@@ -57,7 +87,8 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
@Query(value = """ @Query(value = """
UPDATE user_steam_sync UPDATE user_steam_sync
SET last_synced_at = :now, SET last_synced_at = :now,
last_sync_status = :status last_sync_status = :status,
last_success_at = CASE WHEN :status = 'OK' THEN :now ELSE last_success_at END
WHERE user_id = :userId WHERE user_id = :userId
AND sync_started_at = :startedAt AND sync_started_at = :startedAt
""", nativeQuery = true) """, nativeQuery = true)
@@ -15,7 +15,9 @@ import org.springframework.stereotype.Service;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List; import java.util.List;
@Service @Service
@@ -24,12 +26,14 @@ import java.util.List;
@Slf4j @Slf4j
public class LibrarySyncService { public class LibrarySyncService {
private static final LocalTime NIGHTLY_SKIP_FROM = LocalTime.of(23, 0);
private final UserSteamSyncRepository userSteamSyncRepository; private final UserSteamSyncRepository userSteamSyncRepository;
private final LibrarySyncWorker worker; private final LibrarySyncWorker worker;
private final BacklogSnapshotRepository backlogSnapshotRepository; private final BacklogSnapshotRepository backlogSnapshotRepository;
private final FinishedEstimateService finishedEstimateService; private final FinishedEstimateService finishedEstimateService;
@Value("${app.library.sync-ttl-hours:24}") @Value("${app.library.sync-ttl-hours:20}")
private long syncTtlHours; private long syncTtlHours;
@Value("${app.library.force-sync-cooldown-seconds:60}") @Value("${app.library.force-sync-cooldown-seconds:60}")
@@ -38,8 +42,8 @@ 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}") @Value("${app.library.login-retry-minutes:60}")
private long failedRetryMinutes; private long loginRetryMinutes;
@Value("${app.library.nightly-active-days:14}") @Value("${app.library.nightly-active-days:14}")
private long nightlyActiveDays; private long nightlyActiveDays;
@@ -48,36 +52,51 @@ public class LibrarySyncService {
private ZoneId zone; private ZoneId zone;
/** /**
* Trigger sync only if data is older than TTL or was synced for another Steam ID. * Starts a background sync on a visit when the last OK sync is older than the TTL, at most one try per
* Cheap gate check on caller thread, actual sync runs async via worker. * {@code login-retry-minutes} (a dropped or failed try counts too), or when the Steam ID changed.
* The check on the loaded row saves the write; the conditional upsert lets one of many parallel requests win.
*
* @return true when a sync of this visit is running or was just started, by this call or by a parallel
* request that won the gate
*/ */
public void syncIfStale(Long userId, String steamId) { public boolean syncIfStale(Long userId, String steamId) {
Instant now = Instant.now();
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null); UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
touchUserSeen(sync); touchUserSeen(sync);
if (sync != null && isAlreadyRunning(sync)) { Instant successBefore = now.minus(Duration.ofHours(syncTtlHours));
log.debug("Sync already running for user {}", userId); Instant triedBefore = now.minus(Duration.ofMinutes(loginRetryMinutes));
return; Instant runningStaleBefore = now.minus(Duration.ofMinutes(runningStaleMinutes));
if (sync != null && !isLoginSyncDue(sync, steamId, successBefore, triedBefore)) {
return isSyncActive(sync, now);
} }
if (sync != null && steamId.equals(sync.getSteamId()) && !isStale(sync)) { if (userSteamSyncRepository.tryStartLoginSync(userId, steamId, now, successBefore, triedBefore,
return; runningStaleBefore) == 0) {
// Lost the gate: the winner may be a parallel request that wrote after our read.
return userSteamSyncRepository.findById(userId).map(s -> 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 * 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 * 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 * thread; accounts without a user request in the last {@code nightly-active-days}, or with an OK sync of the
* user's pile size of the day goes to backlog_snapshot, and old finished games get a date from their ending * same day after 23:00, are skipped (the nightly sync books the playtime growth of the
* achievement. * 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}") @Scheduled(cron = "${app.library.nightly-sync-cron:0 55 23 * * *}", zone = "${app.library.zone:Europe/Berlin}")
public void syncAllNightly() { public void syncAllNightly() {
Instant activeSince = Instant.now().minus(Duration.ofDays(nightlyActiveDays)); Instant now = Instant.now();
List<UserSteamSync> accounts = userSteamSyncRepository.findByUserSeenAtAfter(activeSince); LocalDate today = LocalDate.now(zone);
log.info("Nightly sync for {} Steam accounts", accounts.size()); List<UserSteamSync> active =
userSteamSyncRepository.findByUserSeenAtAfter(now.minus(Duration.ofDays(nightlyActiveDays)));
List<UserSteamSync> 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. // Taken before the syncs: the loop can run past midnight.
LocalDate day = LocalDate.now(zone); LocalDate day = today;
try { try {
accounts.forEach(sync -> worker.runSyncNow(sync.getUserId(), sync.getSteamId(), SyncTrigger.AUTO)); accounts.forEach(sync -> worker.runSyncNow(sync.getUserId(), sync.getSteamId(), SyncTrigger.AUTO));
} finally { } finally {
@@ -136,12 +155,29 @@ public class LibrarySyncService {
} }
} }
private boolean isStale(UserSteamSync sync) { private boolean isLoginSyncDue(UserSteamSync sync, String steamId, Instant successBefore, Instant triedBefore) {
if (sync.getLastSyncedAt() == null) return true; return !isAlreadyRunning(sync)
Duration maxAge = sync.getLastSyncStatus() == UserSteamSync.Status.FAILED && (!steamId.equals(sync.getSteamId())
? Duration.ofMinutes(failedRetryMinutes) || (isNullOrBefore(sync.getLastSuccessAt(), successBefore)
: Duration.ofHours(syncTtlHours); && isNullOrBefore(sync.getLoginSyncTriedAt(), triedBefore)));
return sync.getLastSyncedAt().isBefore(Instant.now().minus(maxAge)); }
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) { private boolean isAlreadyRunning(UserSteamSync sync) {
+3 -1
View File
@@ -56,7 +56,9 @@ app:
enabled: true enabled: true
base-url: https://www.speedrun.com/api/v1 base-url: https://www.speedrun.com/api/v1
library: 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 force-sync-cooldown-seconds: 60
running-stale-minutes: 5 running-stale-minutes: 5
achievement-ttl-hours: 1 achievement-ttl-hours: 1
@@ -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';
@@ -16,6 +16,7 @@ import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertTrue; 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.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** /**
@@ -46,7 +47,9 @@ class LibraryControllerSteamIdGoneTest extends AbstractIntegrationTest {
@Test @Test
void gamesCount_WithoutSteamIdClaim_StopsTheNightlySyncOfTheOldId() throws Exception { 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() assertTrue(userSteamSyncRepository.findByUserSeenAtAfter(Instant.now().minus(Duration.ofDays(14))).stream()
.noneMatch(sync -> sync.getUserId() == USER_ID)); .noneMatch(sync -> sync.getUserId() == USER_ID));
@@ -5,8 +5,17 @@ import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate; 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.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; import static org.assertj.core.api.Assertions.assertThat;
@@ -68,4 +77,188 @@ class UserSteamSyncRepositoryTest extends AbstractPersistenceTest {
assertThat(claim(5L)).isZero(); 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.List;
import java.util.Optional; 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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
@@ -53,72 +55,107 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
ReflectionTestUtils.setField(syncService, "syncTtlHours", 24L); ReflectionTestUtils.setField(syncService, "syncTtlHours", 20L);
ReflectionTestUtils.setField(syncService, "forceSyncCooldownSeconds", 60L); ReflectionTestUtils.setField(syncService, "forceSyncCooldownSeconds", 60L);
ReflectionTestUtils.setField(syncService, "runningStaleMinutes", 5L); ReflectionTestUtils.setField(syncService, "runningStaleMinutes", 5L);
ReflectionTestUtils.setField(syncService, "failedRetryMinutes", 15L); ReflectionTestUtils.setField(syncService, "loginRetryMinutes", 60L);
ReflectionTestUtils.setField(syncService, "zone", ZoneId.of("Europe/Berlin")); 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); UserSteamSync sync = new UserSteamSync(USER_ID, steamId);
sync.setLastSyncStatus(status); sync.setLastSyncStatus(status);
sync.setLastSyncedAt(Instant.now().minus(age)); sync.setLastSyncedAt(Instant.now());
sync.setSyncStartedAt(Instant.now().minus(age)); 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)); 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 @Test
void syncIfStale_NoSyncYet_Runs() { void syncIfStale_NoSyncYet_AsksTheGateAndRuns() {
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.empty()); 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 @Test
void syncIfStale_FreshOkSync_Skips() { void syncIfStale_SuccessNineteenHoursAgo_SkipsWithoutAWrite() {
givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofMinutes(10)); 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()); verify(worker, never()).runSync(anyLong(), anyString(), any());
} }
@Test @Test
void syncIfStale_FreshSyncForOtherSteamId_Runs() { void syncIfStale_FreshSuccessForOtherSteamId_AsksTheGate() {
givenSync("76561198005679790", UserSteamSync.Status.OK, Duration.ofMinutes(10)); 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 @Test
void syncIfStale_FailedSyncAfterRetryInterval_Runs() { void syncIfStale_RunningForOtherSteamId_SkipsTheGateAndReportsTheRunningSync() {
givenSync(STEAM_ID, UserSteamSync.Status.FAILED, Duration.ofMinutes(16)); givenSync("76561198005679790", UserSteamSync.Status.RUNNING, Duration.ofHours(30), null);
syncService.syncIfStale(USER_ID, STEAM_ID); assertTrue(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);
verifyGateNotCalled();
verify(worker, never()).runSync(anyLong(), anyString(), any()); verify(worker, never()).runSync(anyLong(), anyString(), any());
} }
@@ -136,6 +173,54 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
order.verify(finishedEstimateService).fillMissing(); 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 @Test
void syncAllNightly_SnapshotFails_TheFinishDatesAreStillEstimated() { void syncAllNightly_SnapshotFails_TheFinishDatesAreStillEstimated() {
when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of()); when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of());
@@ -174,11 +259,11 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
void syncIfStale_MarksTheUserAsSeenAtMostHourly() { void syncIfStale_MarksTheUserAsSeenAtMostHourly() {
UserSteamSync seenLongAgo = new UserSteamSync(USER_ID, STEAM_ID); UserSteamSync seenLongAgo = new UserSteamSync(USER_ID, STEAM_ID);
seenLongAgo.setLastSyncStatus(UserSteamSync.Status.OK); seenLongAgo.setLastSyncStatus(UserSteamSync.Status.OK);
seenLongAgo.setLastSyncedAt(Instant.now()); seenLongAgo.setLastSuccessAt(Instant.now());
seenLongAgo.setUserSeenAt(Instant.now().minus(Duration.ofDays(2))); seenLongAgo.setUserSeenAt(Instant.now().minus(Duration.ofDays(2)));
UserSteamSync seenJustNow = new UserSteamSync(USER_ID, STEAM_ID); UserSteamSync seenJustNow = new UserSteamSync(USER_ID, STEAM_ID);
seenJustNow.setLastSyncStatus(UserSteamSync.Status.OK); seenJustNow.setLastSyncStatus(UserSteamSync.Status.OK);
seenJustNow.setLastSyncedAt(Instant.now()); seenJustNow.setLastSuccessAt(Instant.now());
seenJustNow.setUserSeenAt(Instant.now()); seenJustNow.setUserSeenAt(Instant.now());
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(seenLongAgo), Optional.of(seenJustNow)); 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()); 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 @Test
void runSync_WithGames_MarksOk() { void runSync_WithGames_MarksOk() {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1); when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);