Public Access
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:
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")
|
||||
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")
|
||||
|
||||
@@ -2,5 +2,6 @@ package core_service.modules.library.domain;
|
||||
|
||||
public enum SyncTrigger {
|
||||
MANUAL,
|
||||
AUTO
|
||||
AUTO,
|
||||
LOGIN
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -38,7 +38,9 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
|
||||
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 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',
|
||||
sync_started_at = EXCLUDED.sync_started_at
|
||||
WHERE user_steam_sync.last_sync_status <> 'RUNNING'
|
||||
@@ -50,6 +52,34 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
|
||||
@Param("startedAt") Instant startedAt,
|
||||
@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
|
||||
*/
|
||||
@@ -57,7 +87,8 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
|
||||
@Query(value = """
|
||||
UPDATE user_steam_sync
|
||||
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
|
||||
AND sync_started_at = :startedAt
|
||||
""", nativeQuery = true)
|
||||
|
||||
@@ -15,7 +15,9 @@ import org.springframework.stereotype.Service;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@@ -24,12 +26,14 @@ import java.util.List;
|
||||
@Slf4j
|
||||
public class LibrarySyncService {
|
||||
|
||||
private static final LocalTime NIGHTLY_SKIP_FROM = LocalTime.of(23, 0);
|
||||
|
||||
private final UserSteamSyncRepository userSteamSyncRepository;
|
||||
private final LibrarySyncWorker worker;
|
||||
private final BacklogSnapshotRepository backlogSnapshotRepository;
|
||||
private final FinishedEstimateService finishedEstimateService;
|
||||
|
||||
@Value("${app.library.sync-ttl-hours:24}")
|
||||
@Value("${app.library.sync-ttl-hours:20}")
|
||||
private long syncTtlHours;
|
||||
|
||||
@Value("${app.library.force-sync-cooldown-seconds:60}")
|
||||
@@ -38,8 +42,8 @@ public class LibrarySyncService {
|
||||
@Value("${app.library.running-stale-minutes:5}")
|
||||
private long runningStaleMinutes;
|
||||
|
||||
@Value("${app.library.failed-retry-minutes:15}")
|
||||
private long failedRetryMinutes;
|
||||
@Value("${app.library.login-retry-minutes:60}")
|
||||
private long loginRetryMinutes;
|
||||
|
||||
@Value("${app.library.nightly-active-days:14}")
|
||||
private long nightlyActiveDays;
|
||||
@@ -48,36 +52,51 @@ public class LibrarySyncService {
|
||||
private ZoneId zone;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Starts a background sync on a visit when the last OK sync is older than the TTL, at most one try per
|
||||
* {@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);
|
||||
touchUserSeen(sync);
|
||||
if (sync != null && isAlreadyRunning(sync)) {
|
||||
log.debug("Sync already running for user {}", userId);
|
||||
return;
|
||||
Instant successBefore = now.minus(Duration.ofHours(syncTtlHours));
|
||||
Instant triedBefore = now.minus(Duration.ofMinutes(loginRetryMinutes));
|
||||
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)) {
|
||||
return;
|
||||
if (userSteamSyncRepository.tryStartLoginSync(userId, steamId, now, successBefore, triedBefore,
|
||||
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
|
||||
* 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<UserSteamSync> accounts = userSteamSyncRepository.findByUserSeenAtAfter(activeSince);
|
||||
log.info("Nightly sync for {} Steam accounts", accounts.size());
|
||||
Instant now = Instant.now();
|
||||
LocalDate today = LocalDate.now(zone);
|
||||
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.
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
+4
-1
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user