IodidDOCUMENTATION

architecture.mdx

/doc/architecture

PUBLIC DOCUMENTATION

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/ and src/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 + RedisKey are rebuildable read-through caches. No authoritative truth in Redis (src/main/java/com/safjnest/lol/service/MatchService.java:68, ProfileService.java:93).
text
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_MATCHUPS
  • SUMMONER_DATA, SUMMONER_RANK_HISTORY

Read-through pattern (MatchService.find :65):

java
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).

SchedulerRoute typeFileWhat it does
RiotSchedulerLeagueShard (one per shard)scheduler/RiotScheduler.java:7outbound Riot — getMatch, getMatchlist, getSummoner. One worker pool per shard, one queue per shard, optional toggleLogging
ComputeSchedulerDatabaseWorkerType (PROFILE, PROFILE_2, CHAMPION, MONGO)scheduler/ComputeScheduler.java:24Mongo compute work — PROFILE/PROFILE_2 for profile statistics, CHAMPION for tier-list/build matrices, MONGO for generic jobs
SyncSchedulerLeagueShard + UNKNOWN (global)scheduler/SyncScheduler.java:7tracking, rank, match, sample and participant refresh — one queue per shard + one global

ComputeScheduler explicitly registers the 4 routes (:36):

java
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

text
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. Methods immediate/normal/background (:28) delegate to Router.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, maps Class<?> scheduler -> AbstractScheduler. register at boot, submit resolves the scheduler and forwards to Registry.

  • Registry (src/main/java/com/safjnest/lol/queue/Registry.java) — global in-memory registry, tracks the logical parent/child tree of Jobs without merging physical queues. Exposes snapshot() and statusSnapshot(maxDepth) (QueueHandler.java:43) for JobStatus diagnostics.

  • Job (src/main/java/com/safjnest/lol/queue/job/Job.java) — registered data object with key (deduplication), name, priority (JobPriority.java), state (JobState.java), tracking phase/item (task.phase("FETCH") in MatchService.fetchAndInsert :415). The key avoids duplicates: ComputeScheduler reuses an existing ChampionMatrixRequest if already queued (ComputeScheduler.enqueueChampionStatsMatrix :285).

  • AbstractScheduler + JobQueue + JobWorker (queue/worker/JobQueue.java, JobWorker.java) — each scheduler owns a JobQueue per route (priority ordered IMMEDIATE > NORMAL > BACKGROUND). JobWorker drains the queue on dedicated threads (workerThreadName per scheduler: r4j-{shard}-, lol-db-profile-worker-, lol-sync-{shard}-). Dispatcher and lifecycle locks handle orderly shutdown (shutdownDispatcher).

Submit flow (ComputeScheduler.startProfileStatistics :236):

java
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 with tracked=false. It can never replace an already completed match. If inserted is false, it returns find(fullGameId, shard) (:145).
  • Tracker (src/main/java/com/safjnest/lol/tracker/Tracker.java) owns the tracked=false -> true transition. It completes the normal RankProgress history and commits the single step to true only when all RankProgress of ranked participants are consistent.
  • Explicit exception %test fix-tracked — after rebuilding ranked history, marks every repaired ranked match tracked=true.
  • OP.GG source may persist best-effort snapshot { rank, lp } for a participant, but never gain or predecessor data.

Pre-insert validation (MatchService.insert :130):

java
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

text
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):

text
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.