refactor(library): two bounded background pools

Every @Async names a pool: with the scheduler there are two TaskExecutor beans, and a bare @Async ran on an unbounded executor. Page views queue only stale games; the long runs after a sync get their own pool.
This commit is contained in:
lobo committed 2026-09-30 12:08:57 +02:00
1 parent 9a09d535fc
commit 2c97e2ae96
15 files changed
+373 -54

No files matched your search

+34 -2
View File
@@ -98,7 +98,7 @@ only guard.
Every service method a controller calls carries one role marker, on the method or on its class (the method wins):
`@RequiresRole(Role.X)`, `@Public` (no login, e.g. landing stats; the public profile checks its ticket) or
`@Internal` (only Auth's service token). A Spring Security method interceptor checks it while a request is served;
scheduled jobs and `@Async` work run without a user and are not checked. A denied call answers `401` without a
scheduled jobs and background work run without a user and are not checked. A denied call answers `401` without a
login, else `403`. The URL rules in `SecurityConfig` stay as the outer guard. ArchUnit fails the build for an
unmarked method, two markers, a controller calling an `@Async` method (the check would never run), and a service
method calling another service's method that needs more.
@@ -221,7 +221,7 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
Afterwards it writes every user's pile size of the day to `backlog_snapshot`, also for users without Steam, and
dates finished games by the unlock of their ending achievement
(`FinishedEstimateService`, `user_game.finished_at_estimate`).
3. `LibrarySyncWorker` runs in the background. `user_steam_sync` lets only one run per user start, and none for
3. `LibrarySyncWorker` runs on the background pool. `user_steam_sync` lets only one run per user start, and none for
a user in `deleted_user`.
4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds
the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer
@@ -239,6 +239,38 @@ The frontend polls `GET /api/library/sync-status` until the run ends.
At 01:30 `ImageRepairService` repairs the pictures reported through `POST /api/library/broken-images` (table
`broken_image`, one row per game and kind). Reports only count for Steam games the user owns.
### Background work
Background work runs on two pools (`AsyncConfig`; every `@Async` names one, ArchUnit checks it):
- The default pool (4 threads, queue 100) runs the syncs and the time-to-beat and mood refreshes a page view
starts. A page view queues only games without a fresh row. When the queue is full, the task is dropped with
a warning, and the next view or sync asks again. Known limit: a dropped sync still answers `202`; the user
starts it again.
- The follow-up pool (2 threads, no limit on the queue) runs the achievement runs after a sync and every speedrun
refresh. They hold a thread for minutes because of the Steam and speedrun.com pace, so they do not take the page
views' threads. A task is small (a user or a few appids), and none is dropped.
Imports have their own two threads, and `game_import` is their queue. Scheduled jobs (nightly sync, image repair,
import watchdog) run on the scheduler.
```mermaid
flowchart LR
View["page view, add, merge"] -->|"stale games only"| Pool["default pool<br/>4 threads, queue 100"]
Sync["sync request"] --> Pool
Synced["sync, after commit"] --> FollowUp["follow-up pool<br/>2 threads"]
View -->|"speedrun"| FollowUp
Pool --> Apis["Steam, IGDB, speedrun.com"]
FollowUp --> Apis
Import["POST /imports"] --> Lanes["GameImportWorker<br/>2 own threads"]
Cron["scheduler"] --> Jobs["nightly sync, image repair, import watchdog"]
```
Core runs as one instance. The pools, the speedrun queue, the per-user achievement runs and the achievement locks
live in memory: a second instance would ask Steam and IGDB twice, and the database would stay consistent (sync
claim, upserts). A restart drops what waits in the pools; it is all refresh work and is asked again. Imports survive
a restart.
### Corrections by an auditor
An AUDITOR (active role) corrects a game's name, header image, speedrun link, moods, endless flag and genres for
@@ -1,9 +1,46 @@
package core_service.configuration;
import core_service.shared.background.BackgroundPools;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
/**
* The two pools of {@link BackgroundPools}. A dropped task is a refresh of data with a TTL: the next view or sync
* asks again.
*/
@Configuration
@EnableAsync
@Slf4j
public class AsyncConfig {
// A task holds a connection only for its short transactions. At most 6 background tasks, plus the scheduler and
// the 2 import lanes, use Hikari's 10 connections at once; requests share the rest.
static final int THREADS = 4;
static final int QUEUE = 100;
// The achievement runs share one Steam pace and speedrun has one drainer: more threads would only wait.
static final int FOLLOW_UP_THREADS = 2;
@Bean(name = BackgroundPools.DEFAULT)
public ThreadPoolTaskExecutor applicationTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(THREADS);
executor.setMaxPoolSize(THREADS);
executor.setQueueCapacity(QUEUE);
executor.setThreadNamePrefix("background-");
executor.setRejectedExecutionHandler((task, pool) -> log.warn(pool.isShutdown()
? "Background pool stopped, task dropped" : "Background queue full, task dropped"));
return executor;
}
@Bean(name = BackgroundPools.SYNC_FOLLOW_UP)
public ThreadPoolTaskExecutor syncFollowUpExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(FOLLOW_UP_THREADS);
executor.setMaxPoolSize(FOLLOW_UP_THREADS);
executor.setThreadNamePrefix("sync-follow-up-");
return executor;
}
}
@@ -15,6 +15,7 @@ import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse;
import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.background.BackgroundPools;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
@@ -93,7 +94,7 @@ public class AchievementSyncService {
}
}
@Async
@Async(BackgroundPools.SYNC_FOLLOW_UP)
@TransactionalEventListener
public void onLibrarySynced(LibrarySyncedEvent event) {
syncLibrary(event.userId(), event.steamId());
@@ -37,6 +37,7 @@ import java.util.Set;
import java.util.stream.Collectors;
import core_service.modules.library.domain.UserSteamSync.Status;
import core_service.shared.background.BackgroundPools;
/**
* Worker bean. Runs the actual Steam fetch + DB upserts. Separate bean so the Spring proxy applies @Async
@@ -68,7 +69,7 @@ public class LibrarySyncWorker {
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
@Async
@Async(BackgroundPools.DEFAULT)
public void runSync(Long userId, String steamId) {
sync(userId, steamId);
}
@@ -7,15 +7,13 @@ import core_service.modules.library.repo.UserGameRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.core.task.TaskExecutor;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
/**
* Stores the IGDB genres of games and the {@link Mood}s from IGDB's genres, themes and game modes, the same way
@@ -29,15 +27,14 @@ public class MoodService {
private final IgdbApiService igdbApiService;
private final GameMoodsRepository gameMoodsRepository;
private final UserGameRepository userGameRepository;
private final TaskExecutor applicationTaskExecutor;
@Value("${app.library.mood-ttl-days:30}")
private long ttlDays;
public void refresh(Collection<Long> appids) {
if (!igdbApiService.isEnabled() || appids.isEmpty()) return;
// Sorted: parallel syncs then lock the upserted rows in the same order and cannot deadlock.
Set<Long> stale = new TreeSet<>(appids);
stale.removeAll(gameMoodsRepository.findFreshAppids(appids, Instant.now().minus(Duration.ofDays(ttlDays))));
Set<Long> stale = stale(appids);
if (stale.isEmpty()) return;
IgdbLookup.Answer<IgdbApiService.GameTags> answer = IgdbLookup.byAppid(stale, igdbApiService::getGameTags,
@@ -62,36 +59,31 @@ public class MoodService {
/**
* Same as {@link #refresh}, but the request does not wait for IGDB, and an error does not fail it.
*/
@Async
public void refreshInBackground(Collection<Long> appids) {
try {
refresh(appids);
} catch (Exception e) {
log.warn("Moods of {} not refreshed: {}", appids, e.getMessage());
}
if (!igdbApiService.isEnabled()) return;
Refreshes.staleInBackground(applicationTaskExecutor, "Moods of " + appids, () -> appids, this::stale,
this::refresh);
}
/**
* For the genres and moods of the stats page: a user without a Steam sync gets them only here.
*/
@Async
public void refreshListedInBackground(Long userId) {
try {
refresh(userGameRepository.findAllListedAppids(userId));
} catch (Exception e) {
log.warn("Moods of the library of user {} not refreshed: {}", userId, e.getMessage());
}
if (!igdbApiService.isEnabled()) return;
Refreshes.staleInBackground(applicationTaskExecutor, "Moods of the library of user " + userId,
() -> userGameRepository.findAllListedAppids(userId), this::stale, this::refresh);
}
/**
* For the picker: a user without a Steam sync gets moods only here, and a Steam user before the next sync.
*/
@Async
public void refreshBacklogInBackground(Long userId) {
try {
refresh(userGameRepository.findBacklogAppids(userId));
} catch (Exception e) {
log.warn("Backlog moods of user {} not refreshed: {}", userId, e.getMessage());
if (!igdbApiService.isEnabled()) return;
Refreshes.staleInBackground(applicationTaskExecutor, "Backlog moods of user " + userId,
() -> userGameRepository.findBacklogAppids(userId), this::stale, this::refresh);
}
private Set<Long> stale(Collection<Long> appids) {
return Refreshes.stale(appids, gameMoodsRepository::findFreshAppids, ttlDays);
}
}
@@ -0,0 +1,64 @@
package core_service.modules.library.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.task.TaskExecutor;
import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* The shared steps of the IGDB and speedrun.com refreshes: which games have no fresh row, and the background run.
*/
@Slf4j
final class Refreshes {
private Refreshes() {
}
/**
* Sorted: parallel syncs then lock the upserted rows in the same order and cannot deadlock.
*
* @param findFreshAppids the appids among the given ones with a row newer than the instant
*/
static Set<Long> stale(Collection<Long> appids, BiFunction<Collection<Long>, Instant, Set<Long>> findFreshAppids,
long ttlDays) {
Set<Long> stale = new TreeSet<>(appids);
stale.removeAll(findFreshAppids.apply(appids, Instant.now().minus(Duration.ofDays(ttlDays))));
return stale;
}
/**
* Checks on the calling thread which games are stale and refreshes only those in the background, so a fresh game
* takes no place in the queue. No error reaches the caller: the next view or sync asks again.
*
* @param appids read on the calling thread
*/
static void staleInBackground(TaskExecutor executor, String what, Supplier<Collection<Long>> appids,
Function<Collection<Long>, Set<Long>> stale, Consumer<Set<Long>> refresh) {
try {
Collection<Long> candidates = appids.get();
if (candidates.isEmpty()) return;
Set<Long> staleAppids = stale.apply(candidates);
if (staleAppids.isEmpty()) return;
// ponytail: views of one stale game in the same second ask once each; an in-flight set if that hurts
executor.execute(() -> quietly(what, () -> refresh.accept(staleAppids)));
} catch (Exception e) {
log.warn("{} not refreshed: {}", what, e.getMessage());
}
}
static void quietly(String what, Runnable refresh) {
try {
refresh.run();
} catch (Exception e) {
log.warn("{} not refreshed: {}", what, e.getMessage());
}
}
}
@@ -8,9 +8,11 @@ import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
import core_service.shared.background.BackgroundPools;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.task.TaskExecutor;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.event.TransactionalEventListener;
@@ -46,6 +48,7 @@ public class SpeedrunService {
private final GameSpeedrunRepository gameSpeedrunRepository;
private final SteamGameRepository steamGameRepository;
private final UserGameRepository userGameRepository;
private final TaskExecutor syncFollowUpExecutor;
private final ConcurrentSkipListSet<Long> pending = new ConcurrentSkipListSet<>();
private final AtomicBoolean running = new AtomicBoolean();
@@ -71,28 +74,27 @@ public class SpeedrunService {
}
}
@Async
@Async(BackgroundPools.SYNC_FOLLOW_UP)
@TransactionalEventListener
public void onLibrarySynced(LibrarySyncedEvent event) {
refreshBacklogInBackground(event.userId());
Refreshes.quietly("Speedrun games of the backlog of user " + event.userId(),
() -> refresh(userGameRepository.findBacklogAppids(event.userId())));
}
@Async
public void refreshBacklogInBackground(Long userId) {
try {
refresh(userGameRepository.findBacklogAppids(userId));
} catch (Exception e) {
log.warn("Speedrun games of the backlog of user {} not refreshed: {}", userId, e.getMessage());
}
if (!speedrunApiService.isEnabled()) return;
Refreshes.staleInBackground(syncFollowUpExecutor, "Speedrun games of the backlog of user " + userId,
() -> userGameRepository.findBacklogAppids(userId), this::stale, this::refresh);
}
@Async
public void refreshInBackground(Collection<Long> appids) {
try {
refresh(appids);
} catch (Exception e) {
log.warn("Speedrun games of {} not refreshed: {}", appids, e.getMessage());
if (!speedrunApiService.isEnabled()) return;
Refreshes.staleInBackground(syncFollowUpExecutor, "Speedrun games of " + appids, () -> appids, this::stale,
this::refresh);
}
private Set<Long> stale(Collection<Long> appids) {
return Refreshes.stale(appids, gameSpeedrunRepository::findFreshAppids, ttlDays);
}
private void drain() {
@@ -5,15 +5,13 @@ import core_service.modules.library.repo.GameTimeToBeatRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.core.task.TaskExecutor;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
@Service
@RequiredArgsConstructor
@@ -22,20 +20,19 @@ public class TimeToBeatService {
private final IgdbApiService igdbApiService;
private final GameTimeToBeatRepository gameTimeToBeatRepository;
private final TaskExecutor applicationTaskExecutor;
@Value("${app.library.time-to-beat-ttl-days:30}")
private long ttlDays;
/**
* Same as {@link #refresh}, but the request does not wait for IGDB, and an error does not fail it.
* Same as {@link #refresh}, but the request does not wait for IGDB, and an error does not fail it. Only stale apps
* take a place in the background queue.
*/
@Async
public void refreshInBackground(Collection<Long> appids) {
try {
refresh(appids);
} catch (Exception e) {
log.warn("Time to beat of {} not refreshed: {}", appids, e.getMessage());
}
if (!igdbApiService.isEnabled()) return;
Refreshes.staleInBackground(applicationTaskExecutor, "Time to beat of " + appids, () -> appids, this::stale,
this::refresh);
}
/**
@@ -44,9 +41,7 @@ public class TimeToBeatService {
*/
public void refresh(Collection<Long> appids) {
if (!igdbApiService.isEnabled() || appids.isEmpty()) return;
// Sorted: parallel syncs then lock the upserted rows in the same order and cannot deadlock.
Set<Long> stale = new TreeSet<>(appids);
stale.removeAll(gameTimeToBeatRepository.findFreshAppids(appids, Instant.now().minus(Duration.ofDays(ttlDays))));
Set<Long> stale = stale(appids);
if (stale.isEmpty()) return;
IgdbLookup.Answer<IgdbApiService.TimeToBeat> answer = IgdbLookup.byAppid(stale, igdbApiService::getTimeToBeat,
@@ -66,4 +61,8 @@ public class TimeToBeatService {
}
}
}
private Set<Long> stale(Collection<Long> appids) {
return Refreshes.stale(appids, gameTimeToBeatRepository::findFreshAppids, ttlDays);
}
}
@@ -0,0 +1,22 @@
package core_service.shared.background;
import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration;
/**
* The bean names of the two background pools (configuration.AsyncConfig). Every {@code @Async} names one of them:
* with the scheduler there are two TaskExecutor beans, and a bare {@code @Async} would run unbounded.
*/
public final class BackgroundPools {
/** Syncs and the refreshes a page view starts: short tasks, a full queue drops the task. */
public static final String DEFAULT = TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME;
/**
* The achievement runs after a sync and every speedrun refresh: they hold a thread for minutes (Steam and
* speedrun.com pace), so page views keep the default pool. Small tasks, nothing is dropped.
*/
public static final String SYNC_FOLLOW_UP = "syncFollowUpExecutor";
private BackgroundPools() {
}
}
@@ -10,7 +10,7 @@ import java.lang.annotation.Target;
* The lowest active role that may call this service method, or every method of this class. A method marker
* replaces the class marker. Every service method a controller calls carries one of {@link RequiresRole},
* {@link Public} or {@link Internal} (ArchitectureTest). Checked only while an HTTP request is served: scheduled
* jobs and {@code @Async} work run without a user.
* jobs and background work (the pools in {@code AsyncConfig}) run without a user.
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@@ -1,15 +1,24 @@
package core_service.architecture;
import core_service.shared.background.BackgroundPools;
import core_service.shared.test.AbstractArchitectureTest;
import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaMethod;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;
import com.tngtech.archunit.library.dependencies.SlicesRuleDefinition;
import org.springframework.scheduling.annotation.Async;
import java.util.Set;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
/**
@@ -93,6 +102,24 @@ class ArchitectureTest extends AbstractArchitectureTest {
.should().haveSimpleNameEndingWith("Repository")
.because("Repository interfaces are the persistence boundary; naming makes them explicit.");
@ArchTest
static final ArchRule async_methods_should_name_a_pool = methods()
.that().areAnnotatedWith(Async.class)
.should(new ArchCondition<>("name a pool of BackgroundPools") {
@Override
public void check(JavaMethod method, ConditionEvents events) {
String pool = method.getAnnotationOfType(Async.class).value();
boolean named = Set.of(BackgroundPools.DEFAULT, BackgroundPools.SYNC_FOLLOW_UP).contains(pool);
events.add(new SimpleConditionEvent(method, named, method.getFullName() + " runs on '" + pool + "'"));
}
})
.because("A bare @Async runs on an unbounded executor (BackgroundPools).");
@ArchTest
static final ArchRule no_class_level_async = noClasses()
.should().beAnnotatedWith(Async.class)
.because("@Async on a class names no pool for its methods; put it on each method.");
// ===================================================================================
// 3. SHARED / CONFIGURATION RULES
// ===================================================================================
@@ -0,0 +1,72 @@
package core_service.configuration;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AsyncConfigTest extends AbstractUnitTest {
@Test
void fullQueue_DropsTheTaskWithoutFailingTheCaller() throws InterruptedException {
ThreadPoolTaskExecutor executor = new AsyncConfig().applicationTaskExecutor();
executor.initialize();
CountDownLatch release = new CountDownLatch(1);
CountDownLatch started = new CountDownLatch(AsyncConfig.THREADS);
AtomicBoolean droppedRan = new AtomicBoolean();
try {
for (int i = 0; i < AsyncConfig.THREADS; i++) {
executor.execute(() -> {
started.countDown();
awaitQuietly(release);
});
}
assertTrue(started.await(5, TimeUnit.SECONDS));
for (int i = 0; i < AsyncConfig.QUEUE; i++) {
executor.execute(() -> { });
}
assertDoesNotThrow(() -> executor.execute(() -> droppedRan.set(true)));
assertEquals(AsyncConfig.QUEUE, executor.getQueueSize());
} finally {
release.countDown();
executor.shutdown();
}
assertTrue(executor.getThreadPoolExecutor().awaitTermination(5, TimeUnit.SECONDS));
assertFalse(droppedRan.get());
}
@Test
void followUpPool_KeepsEveryTask() throws InterruptedException {
ThreadPoolTaskExecutor executor = new AsyncConfig().syncFollowUpExecutor();
executor.initialize();
int tasks = AsyncConfig.QUEUE * 3;
CountDownLatch done = new CountDownLatch(tasks);
try {
for (int i = 0; i < tasks; i++) {
executor.execute(done::countDown);
}
assertTrue(done.await(5, TimeUnit.SECONDS));
} finally {
executor.shutdown();
}
}
private static void awaitQuietly(CountDownLatch latch) {
try {
latch.await(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
@@ -8,6 +8,9 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.springframework.core.task.SyncTaskExecutor;
import org.springframework.core.task.TaskExecutor;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
@@ -38,6 +41,9 @@ class MoodServiceTest extends AbstractUnitTest {
@Mock
private UserGameRepository userGameRepository;
@Spy
private TaskExecutor applicationTaskExecutor = new SyncTaskExecutor();
@InjectMocks
private MoodService service;
@@ -72,6 +78,16 @@ class MoodServiceTest extends AbstractUnitTest {
verify(gameMoodsRepository).upsert(eq(427520L), any(), any(), eq(true), any(Instant.class));
}
@Test
void refreshInBackground_AllFresh_QueuesNothing() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameMoodsRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of(620L));
service.refreshInBackground(List.of(620L));
verify(applicationTaskExecutor, never()).execute(any());
}
@Test
void refreshBacklogInBackground_RefreshesTheBacklogOfTheUser() {
when(igdbApiService.isEnabled()).thenReturn(true);
@@ -11,6 +11,9 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.springframework.core.task.SyncTaskExecutor;
import org.springframework.core.task.TaskExecutor;
import org.springframework.dao.DataAccessResourceFailureException;
import java.time.Instant;
@@ -44,6 +47,8 @@ class SpeedrunServiceTest extends AbstractUnitTest {
private SteamGameRepository steamGameRepository;
@Mock
private UserGameRepository userGameRepository;
@Spy
private TaskExecutor syncFollowUpExecutor = new SyncTaskExecutor();
@InjectMocks
private SpeedrunService service;
@@ -222,4 +227,14 @@ class SpeedrunServiceTest extends AbstractUnitTest {
verifyNoInteractions(gameSpeedrunRepository, steamGameRepository);
}
@Test
void onLibrarySynced_RefreshesTheBacklogOnTheListenerThread() {
when(userGameRepository.findBacklogAppids(7L)).thenReturn(List.of());
service.onLibrarySynced(new LibrarySyncedEvent(7L, "76561198000000000"));
verify(userGameRepository).findBacklogAppids(7L);
verify(syncFollowUpExecutor, never()).execute(any());
}
}
@@ -7,6 +7,10 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.springframework.core.task.SyncTaskExecutor;
import org.springframework.dao.QueryTimeoutException;
import org.springframework.core.task.TaskExecutor;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
@@ -15,6 +19,7 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.eq;
@@ -31,6 +36,9 @@ class TimeToBeatServiceTest extends AbstractUnitTest {
@Mock
private GameTimeToBeatRepository gameTimeToBeatRepository;
@Spy
private TaskExecutor applicationTaskExecutor = new SyncTaskExecutor();
@InjectMocks
private TimeToBeatService service;
@@ -53,6 +61,37 @@ class TimeToBeatServiceTest extends AbstractUnitTest {
verify(gameTimeToBeatRepository, never()).upsert(eq(220L), any(), any(), any(), any());
}
@Test
void refreshInBackground_AllFresh_QueuesNothing() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameTimeToBeatRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of(220L, 620L));
service.refreshInBackground(List.of(220L, 620L));
verify(applicationTaskExecutor, never()).execute(any());
}
@Test
void refreshInBackground_StaleCheckFails_DoesNotFailTheCaller() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameTimeToBeatRepository.findFreshAppids(anyCollection(), any())).thenThrow(new QueryTimeoutException("slow"));
assertDoesNotThrow(() -> service.refreshInBackground(List.of(620L)));
verify(applicationTaskExecutor, never()).execute(any());
}
@Test
void refreshInBackground_QueuesOnlyTheStaleApps() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameTimeToBeatRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of(220L));
when(igdbApiService.getTimeToBeat(Set.of(620L))).thenReturn(Optional.of(Map.of()));
service.refreshInBackground(List.of(220L, 620L));
verify(igdbApiService).getTimeToBeat(Set.of(620L));
verify(gameTimeToBeatRepository).upsert(eq(620L), isNull(), isNull(), isNull(), any(Instant.class));
}
@Test
void refresh_GamesOffSteam_AreAskedByIgdbIdAndStoredUnderTheirKey() {
when(igdbApiService.isEnabled()).thenReturn(true);