Public Access
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:
1 parent
9a09d535fc
commit
2c97e2ae96
15 files changed
+373
-54
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user