From 2c97e2ae96fd2592c921e10f77150a66a69931f2 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 30 Sep 2026 12:08:57 +0200 Subject: [PATCH] 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. --- readme.md | 36 +++++++++- .../configuration/AsyncConfig.java | 37 ++++++++++ .../service/AchievementSyncService.java | 3 +- .../library/service/LibrarySyncWorker.java | 3 +- .../modules/library/service/MoodService.java | 40 +++++------ .../modules/library/service/Refreshes.java | 64 +++++++++++++++++ .../library/service/SpeedrunService.java | 30 ++++---- .../library/service/TimeToBeatService.java | 25 ++++--- .../shared/background/BackgroundPools.java | 22 ++++++ .../shared/security/RequiresRole.java | 2 +- .../architecture/ArchitectureTest.java | 27 +++++++ .../configuration/AsyncConfigTest.java | 72 +++++++++++++++++++ .../library/service/MoodServiceTest.java | 16 +++++ .../library/service/SpeedrunServiceTest.java | 15 ++++ .../service/TimeToBeatServiceTest.java | 39 ++++++++++ 15 files changed, 375 insertions(+), 56 deletions(-) create mode 100644 src/main/java/core_service/modules/library/service/Refreshes.java create mode 100644 src/main/java/core_service/shared/background/BackgroundPools.java create mode 100644 src/test/java/core_service/configuration/AsyncConfigTest.java diff --git a/readme.md b/readme.md index b0ef529..9342a92 100644 --- a/readme.md +++ b/readme.md @@ -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
4 threads, queue 100"] + Sync["sync request"] --> Pool + Synced["sync, after commit"] --> FollowUp["follow-up pool
2 threads"] + View -->|"speedrun"| FollowUp + Pool --> Apis["Steam, IGDB, speedrun.com"] + FollowUp --> Apis + Import["POST /imports"] --> Lanes["GameImportWorker
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 diff --git a/src/main/java/core_service/configuration/AsyncConfig.java b/src/main/java/core_service/configuration/AsyncConfig.java index cbcc1c0..55188f1 100644 --- a/src/main/java/core_service/configuration/AsyncConfig.java +++ b/src/main/java/core_service/configuration/AsyncConfig.java @@ -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; + } } diff --git a/src/main/java/core_service/modules/library/service/AchievementSyncService.java b/src/main/java/core_service/modules/library/service/AchievementSyncService.java index 190f5fa..09bcbbf 100644 --- a/src/main/java/core_service/modules/library/service/AchievementSyncService.java +++ b/src/main/java/core_service/modules/library/service/AchievementSyncService.java @@ -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()); diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java index 0c40751..88b174c 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -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); } diff --git a/src/main/java/core_service/modules/library/service/MoodService.java b/src/main/java/core_service/modules/library/service/MoodService.java index 5e21f16..36599f7 100644 --- a/src/main/java/core_service/modules/library/service/MoodService.java +++ b/src/main/java/core_service/modules/library/service/MoodService.java @@ -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 appids) { if (!igdbApiService.isEnabled() || appids.isEmpty()) return; - // Sorted: parallel syncs then lock the upserted rows in the same order and cannot deadlock. - Set stale = new TreeSet<>(appids); - stale.removeAll(gameMoodsRepository.findFreshAppids(appids, Instant.now().minus(Duration.ofDays(ttlDays)))); + Set stale = stale(appids); if (stale.isEmpty()) return; IgdbLookup.Answer 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 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 stale(Collection appids) { + return Refreshes.stale(appids, gameMoodsRepository::findFreshAppids, ttlDays); } } diff --git a/src/main/java/core_service/modules/library/service/Refreshes.java b/src/main/java/core_service/modules/library/service/Refreshes.java new file mode 100644 index 0000000..0288447 --- /dev/null +++ b/src/main/java/core_service/modules/library/service/Refreshes.java @@ -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 stale(Collection appids, BiFunction, Instant, Set> findFreshAppids, + long ttlDays) { + Set 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> appids, + Function, Set> stale, Consumer> refresh) { + try { + Collection candidates = appids.get(); + if (candidates.isEmpty()) return; + Set 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()); + } + } +} diff --git a/src/main/java/core_service/modules/library/service/SpeedrunService.java b/src/main/java/core_service/modules/library/service/SpeedrunService.java index 03e0369..c693a31 100644 --- a/src/main/java/core_service/modules/library/service/SpeedrunService.java +++ b/src/main/java/core_service/modules/library/service/SpeedrunService.java @@ -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 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 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 stale(Collection appids) { + return Refreshes.stale(appids, gameSpeedrunRepository::findFreshAppids, ttlDays); } private void drain() { diff --git a/src/main/java/core_service/modules/library/service/TimeToBeatService.java b/src/main/java/core_service/modules/library/service/TimeToBeatService.java index 10b056c..0c32d0a 100644 --- a/src/main/java/core_service/modules/library/service/TimeToBeatService.java +++ b/src/main/java/core_service/modules/library/service/TimeToBeatService.java @@ -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 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 appids) { if (!igdbApiService.isEnabled() || appids.isEmpty()) return; - // Sorted: parallel syncs then lock the upserted rows in the same order and cannot deadlock. - Set stale = new TreeSet<>(appids); - stale.removeAll(gameTimeToBeatRepository.findFreshAppids(appids, Instant.now().minus(Duration.ofDays(ttlDays)))); + Set stale = stale(appids); if (stale.isEmpty()) return; IgdbLookup.Answer answer = IgdbLookup.byAppid(stale, igdbApiService::getTimeToBeat, @@ -66,4 +61,8 @@ public class TimeToBeatService { } } } + + private Set stale(Collection appids) { + return Refreshes.stale(appids, gameTimeToBeatRepository::findFreshAppids, ttlDays); + } } diff --git a/src/main/java/core_service/shared/background/BackgroundPools.java b/src/main/java/core_service/shared/background/BackgroundPools.java new file mode 100644 index 0000000..f346d65 --- /dev/null +++ b/src/main/java/core_service/shared/background/BackgroundPools.java @@ -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() { + } +} diff --git a/src/main/java/core_service/shared/security/RequiresRole.java b/src/main/java/core_service/shared/security/RequiresRole.java index 3adb3fa..92dd5f3 100644 --- a/src/main/java/core_service/shared/security/RequiresRole.java +++ b/src/main/java/core_service/shared/security/RequiresRole.java @@ -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) diff --git a/src/test/java/core_service/architecture/ArchitectureTest.java b/src/test/java/core_service/architecture/ArchitectureTest.java index 110c671..0c39925 100644 --- a/src/test/java/core_service/architecture/ArchitectureTest.java +++ b/src/test/java/core_service/architecture/ArchitectureTest.java @@ -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 // =================================================================================== diff --git a/src/test/java/core_service/configuration/AsyncConfigTest.java b/src/test/java/core_service/configuration/AsyncConfigTest.java new file mode 100644 index 0000000..a3bd41f --- /dev/null +++ b/src/test/java/core_service/configuration/AsyncConfigTest.java @@ -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(); + } + } +} diff --git a/src/test/java/core_service/modules/library/service/MoodServiceTest.java b/src/test/java/core_service/modules/library/service/MoodServiceTest.java index 2cc419f..e1b57c5 100644 --- a/src/test/java/core_service/modules/library/service/MoodServiceTest.java +++ b/src/test/java/core_service/modules/library/service/MoodServiceTest.java @@ -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); diff --git a/src/test/java/core_service/modules/library/service/SpeedrunServiceTest.java b/src/test/java/core_service/modules/library/service/SpeedrunServiceTest.java index f448fb5..845fbd6 100644 --- a/src/test/java/core_service/modules/library/service/SpeedrunServiceTest.java +++ b/src/test/java/core_service/modules/library/service/SpeedrunServiceTest.java @@ -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()); + } } diff --git a/src/test/java/core_service/modules/library/service/TimeToBeatServiceTest.java b/src/test/java/core_service/modules/library/service/TimeToBeatServiceTest.java index 19840b0..0368356 100644 --- a/src/test/java/core_service/modules/library/service/TimeToBeatServiceTest.java +++ b/src/test/java/core_service/modules/library/service/TimeToBeatServiceTest.java @@ -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);