Architecture
2-minute overview — Mongo, Redis, schedulers, queues and match ingestion.
Architecture — 2 minutes
Sources:
docs/architecture/README.md,AGENTS.md,src/main/java/com/safjnest/lol/queue/andsrc/main/java/com/safjnest/lol/service/.
Principle
- MongoDB is the sole runtime store. All LoL documents (
Summoner,Match,ProfileStatistics,Rank, etc.) live in Mongo. If Mongo is empty, the system is empty. - Redis is only ephemeral cache.
RedisClient+RedisKeyare rebuildable read-through caches. No authoritative truth in Redis (src/main/java/com/safjnest/lol/service/MatchService.java:68,ProfileService.java:93).
Client -> Controller (Spring) -> Service -> Redis (hit?) -> Mongo -> (miss) -> async queue -> 202
|-> R4J (via RiotScheduler) -> Mongo -> invalidate Redis
Mongo as sole runtime, Redis cache only
Mongo (com.safjnest.nosql.MongoDB) — collections summoner, match, profileStatistics, profileActivity, profileMatchups, etc. Each entity extends AbstractEntity with collectionName() and entityId() (src/main/java/com/safjnest/lol/model/summoner/Summoner.java:181, src/main/java/com/safjnest/lol/model/match/Match.java:133). Compound indexes on puuid + filter.toSummonerKey() for statistics (docs/architecture/profile-statistics-source-of-truth.md).
Redis (com.safjnest.redis.RedisClient) — typed keys RedisKey:
MATCH_DETAIL(MatchService.java:68),R4J_MATCH(:121),R4J_MATCH_LIST(:375)SUMMONER_OVERVIEW(ProfileService.java:49),SUMMONER_STATISTICS(:341),SUMMONER_ACTIVITY,SUMMONER_MATCHUPSSUMMONER_DATA,SUMMONER_RANK_HISTORY
Read-through pattern (MatchService.find :65):
cached = RedisClient.get(MATCH_DETAIL, Match.class);
if (cached != null) return cached;
stored = MongoDB.findMatch(gameId);
if (stored != null) RedisClient.set(MATCH_DETAIL, stored, ...);
return stored;
Same pattern in ProfileService.getStatistics (:91) and getActivity (:148). Writes always go to Mongo + invalidate/set Redis (ProfileService.invalidate :324, MatchService.invalidate :298).
TTL configured per key in RedisKey (e.g. SUMMONER_RANK_HISTORY.ttlSeconds() MatchService.java:451).
3 schedulers — Riot / Compute / Sync
All extend AbstractScheduler<Route> (src/main/java/com/safjnest/lol/queue/scheduler/AbstractScheduler.java) and are registered by QueueHandler.start() via Router (src/main/java/com/safjnest/lol/queue/QueueHandler.java:19).
| Scheduler | Route type | File | What it does |
|---|---|---|---|
| RiotScheduler | LeagueShard (one per shard) | scheduler/RiotScheduler.java:7 | outbound Riot — getMatch, getMatchlist, getSummoner. One worker pool per shard, one queue per shard, optional toggleLogging |
| ComputeScheduler | DatabaseWorkerType (PROFILE, PROFILE_2, CHAMPION, MONGO) | scheduler/ComputeScheduler.java:24 | Mongo compute work — PROFILE/PROFILE_2 for profile statistics, CHAMPION for tier-list/build matrices, MONGO for generic jobs |
| SyncScheduler | LeagueShard + UNKNOWN (global) | scheduler/SyncScheduler.java:7 | tracking, rank, match, sample and participant refresh — one queue per shard + one global |
ComputeScheduler explicitly registers the 4 routes (:36):
registerRoutes(List.of(DatabaseWorkerType.PROFILE, PROFILE_2, CHAMPION, MONGO));
And applies adaptive routing for profiles (ComputeScheduler.queueFor :214, profileQueue :326): if CHAMPION has heavy tasks (isHeavyChampionTaskKey :341), profiles go to PROFILE/PROFILE_2; otherwise it balances across MONGO/CHAMPION/PROFILE* based on load.
Each scheduler exposes status() returning SchedulerStatus (RiotScheduler.status :34, ComputeScheduler.status :181, SyncScheduler.status :23).
QueueHandler, Router, Registry, JobQueue
QueueHandler --sole public API--> Router.global() --dispatch--> Registry --drain--> JobQueue per route
| | | |
immediate/normal/background register(scheduler) Job, JobState, JobWorker, WorkerState
| submit(scheduler, route, JobPriority,
+-- retain / resume key, name, work) parent/child tree
-
QueueHandler (
src/main/java/com/safjnest/lol/queue/QueueHandler.java:12) — sole public API. Methodsimmediate/normal/background(:28) delegate toRouter.global().submit.retain(job)/resume(job, callback)(:59) for async jobs with children. -
Router (
src/main/java/com/safjnest/lol/queue/Router.java) — global singleton, mapsClass<?> scheduler -> AbstractScheduler.registerat boot,submitresolves the scheduler and forwards toRegistry. -
Registry (
src/main/java/com/safjnest/lol/queue/Registry.java) — global in-memory registry, tracks the logical parent/child tree ofJobs without merging physical queues. Exposessnapshot()andstatusSnapshot(maxDepth)(QueueHandler.java:43) forJobStatusdiagnostics. -
Job (
src/main/java/com/safjnest/lol/queue/job/Job.java) — registered data object withkey(deduplication),name,priority(JobPriority.java),state(JobState.java), trackingphase/item(task.phase("FETCH")inMatchService.fetchAndInsert:415). The key avoids duplicates:ComputeSchedulerreuses an existingChampionMatrixRequestif already queued (ComputeScheduler.enqueueChampionStatsMatrix:285). -
AbstractScheduler + JobQueue + JobWorker (
queue/worker/JobQueue.java,JobWorker.java) — each scheduler owns aJobQueueper route (priority orderedIMMEDIATE > NORMAL > BACKGROUND).JobWorkerdrains the queue on dedicated threads (workerThreadNameper scheduler:r4j-{shard}-,lol-db-profile-worker-,lol-sync-{shard}-). Dispatcher and lifecycle locks handle orderly shutdown (shutdownDispatcher).
Submit flow (ComputeScheduler.startProfileStatistics :236):
String key = "profile-statistics:" + puuid + ":" + filter.toSummonerKey();
return QueueHandler.normal(ComputeScheduler.class, PROFILE, key, name, job -> generateProfileStatistics(...));
Match ingestion — tracked false -> true
Invariant documented in docs/architecture/README.md:106:
MatchService.insert(src/main/java/com/safjnest/lol/service/MatchService.java:126) can only create documents withtracked=false. It can never replace an already completed match. Ifinsertedis false, it returnsfind(fullGameId, shard)(:145).Tracker(src/main/java/com/safjnest/lol/tracker/Tracker.java) owns thetracked=false -> truetransition. It completes the normalRankProgresshistory and commits the single step totrueonly when allRankProgressof ranked participants are consistent.- Explicit exception
%test fix-tracked— after rebuilding ranked history, marks every repaired ranked matchtracked=true. - OP.GG source may persist best-effort snapshot
{ rank, lp }for a participant, but nevergainor predecessor data.
Pre-insert validation (MatchService.insert :130):
if ((!SeasonUtils.isCurrentSplit(source.getGameStartTimestamp())
&& GameQueueTypeUtils.isRankedSolo(source.getQueue())) || MatchUtils.isRemake(source))
return null;
Non-current ranked matches or remakes are discarded before persistence.
queueRecentMatches (:213) and importHistory (:225) queue R4J fetches via RiotScheduler and inserts via SyncScheduler with QueueHandler.retain/resume to handle async child completion.
Read-through cache — complete pattern
GET /api/lol/EUW1/profile/{puuid}
-> ProfileService.get (ProfileService.java:46)
-> Redis SUMMONER_OVERVIEW hit? -> isReady? -> isStale? -> ApiResult.ready/partial
-> miss -> SummonerService.getAsync (RiotScheduler if needed)
-> ProfileStatistics: Redis -> Mongo -> null? -> ComputeScheduler.startProfileStatistics (NORMAL or BACKGROUND)
-> cache set only if !refresh (ProfileService.java:78)
Same for GET /match/{gameId} (MatchService.getDetail :200):
find(gameId, shard) -> Redis HIT -> ApiResult.ready
-> Mongo HIT -> cache + ApiResult.ready
-> MISS -> SyncScheduler queue (BACKGROUND) -> ApiResult.pending (202)
fetchAsync (:111) adds another layer: R4J_MATCH cache in Redis before calling Riot, with key regionName:match:gameId on RiotScheduler (:118).
All writes invalidate the corresponding cache (MatchService.invalidate :298, ProfileService.invalidate :324, invalidateR4JMatch :195) to ensure consistency without short TTLs.
