Leaderboard & Contextual Ranking — Deep Dive
How global and regional rankings are computed with Redis ZSETs, MMR linearization and exact-count absolute ranking.
Leaderboard & Contextual Ranking
Source:
LeaderboardService.java:216,CompetitiveEntry.java:8,TierDivisionUtils.java:78,RedisClient.java:423,RedisKey.java:40,MongoDB.javacompetitive helpers,docs/architecture/adr/0015-contextual-ranking.md,docs/mongo/13-contextual-ranking.md.
1. The competitive Collection — Single Source of Truth
// src/main/java/com/safjnest/lol/model/competitive/CompetitiveEntry.java:8
public record CompetitiveEntry(
String puuid, LeagueShard region, GameQueueType queue,
TierDivisionType tier, long mmr, LaneType primary,
Integer otpChampionId, long lastUpdate
) {}
_id= first 16 bytes ofSHA-256(puuid + ":" + canonicalQueue)MongoDB.java:competitiveIdCOMPETITIVE_ID_BYTES=16Binary— deterministic, no puuid duplication.queueis canonicalGameQueueTypeUtils.canonicalQueue—TEAM_BUILDER_RANKED_SOLO → RANKED_SOLO_5X5. OnlyRANKED_SOLO_5X5+RANKED_FLEX_SRare indexedleaderboardQueues.mmr=TierDivisionUtils.getMmr(tier, lp)TierDivisionUtils.java:78:
base = IRON_IV 0 … DIAMOND_I 2700, MASTER_I 10000, GRANDMASTER_I 20000, CHALLENGER_I 30000
return base <0 ? -1 : base + max(lp,0)
Each division = +100 MMR. Master+ jumps isolate high elo. -1 = UNRANKED/invalid and filtered out.
primary= most-playedLaneTypeaggregated across all champions for that queueCompetitiveService.java:130.otpChampionId= only ifOPTUtils.isOtptrue.
Mongo Indexes (operator-managed, not created at runtime)
From docs/mongo/08-query-inventory.md + _archive/11-leaderboard-rank-indexes.md:41:
| Index | Key | Use |
|---|---|---|
competitive_queue_mmr | {queue:1, mmr:-1} | global page no filter |
competitive_queue_region_mmr | {queue:1, region:1, mmr:-1} | region page |
competitive_queue_primary_mmr … ..._region_primary_otp_mmr | 6 variants with primary/otpChampionId | filtered leaderboard |
competitive_contextual_global | {queue:1, tier:1, mmr:-1} 13:35 | global exact ranking |
competitive_contextual_regional | {queue:1, tier:1, region:1, mmr:-1} 13:40 | regional exact ranking |
Filter builder MongoDB.java:competitiveFilter:
if (rank != null) { MmrRange r = TierDivisionUtils.getMmrRange(rank); gte("mmr", r.minimum()); if (r.maximum()!=null) lt("mmr", r.maximum()); }
if (role != null) eq("primary", role.name());
if (otpChampionId != null) eq("otpChampionId", otpChampionId);
if (!GLOBAL.equals(region)) eq("region", region);
sort mmr:-1, _id:-1
2. Redis ZSET Design — The Exact-Rank Engine
Prefix beebot:lol: (RedisKey.java:67 — beebot_test:lol when testing).
| RedisKey | Pattern | Type | TTL |
|---|---|---|---|
CONTEXTUAL_LEADERBOARD_SEGMENT RedisKey.java:40 | los:leaderboard:{queue}:{scope}:{tier} e.g. los:leaderboard:RANKED_SOLO_5X5:GLOBAL:DIAMOND_I | ZSET member=16B competitiveId, score=mmr | 6h Duration.ofHours(6) — 0 (persist) if isHighElo |
CONTEXTUAL_LEADERBOARD_COUNTS 41 | los:leaderboard:counts:{queue}:{scope} | HASH field=tier.name → count | 0 persistent |
CONTEXTUAL_LEADERBOARD_SEGMENTS 42 | los:leaderboard:segments | SET of segment keys | persistent |
CONTEXTUAL_LEADERBOARD_ACCESS 43 | los:leaderboard:access | HASH segment → lastAccess millis | persistent |
CONTEXTUAL_LEADERBOARD_BUILD 47 | los:leaderboard:building:{uuid} | temp ZSET | 10m |
LEADERBOARD_PAGE 35 | los:leaderboard:page:{rank}:{queue}:{region}:{role}:{otp}:{page}:{limit} | STRING LeaderboardPage JSON | 60s TEMPORARY_TTL |
LEADERBOARD_COUNT 36 / LOCK 37 | los:leaderboard:count:{queue}:{region}:{rank}:{role}:{otp} | STRING long / NX lock 60s | 60s / 1m |
Member encoding LeaderboardService.java:479:
byte[] member(String puuid, GameQueueType queue) {
Binary id = MongoDB.competitiveId(puuid, canonicalQueue(queue).name()); // SHA-256(puuid:queue)
return id.getData(); // first 16 bytes, binary-safe via byte[]
}
ZADD uses binary key key.getBytes(UTF_8) + member bytes RedisClient.java:361. Batch 250 SORTED_SET_BATCH_SIZE.
Counts hash RedisClient.java:496 replaceHash del + hset + persist. incrementHashIfPresent hincrBy only if exists.
3. Read Path — GET /api/lol/leaderboard
LeaderboardService.java:52-128 LeaderboardController.java:24:
1. Validate: page>=1, limit 1..50 (MAX_PAGE_SIZE)
2. canonicalQueue(defaultQueue(queue)) → RANKED_SOLO_5X5 default
selectedRegion = LeagueShardUtils.defaultRegion(region) → "GLOBAL" if null else shard.name()
rankKey = rank==null ? "ALL" : rank.name(), roleKey, otpKey
3. key = LEADERBOARD_PAGE.of(rankKey, queue, region, roleKey, otpKey, page, limit)
cached = Redis.get(key) → return ready if hit
4. offset = (page-1)*limit
summoners = MongoDB.findLeaderboardPage(rank, queue, region, role, otp, offset, limit) // competitive → _id IN summoner
total = findLeaderboardCount(rank, queue, region, role, otp) // see §5
pages = ceil(total/limit)
5. Filter canonical = SeasonUtils.getCurrentSeasonRange()
group puuids by shard → profileService.getStatistics(puuidsByShard, Filter.canonical())
missing statistics → ComputeScheduler.startProfileStatistics(..., true) (background)
6. rankingSubjects = summoners.map(puuid → ranks.get(queue) ?? unranked)
rankings = resolveRankings(rankingSubjects) // §4
7. leaderboardSummoners = summoners.map(i → new SummonerLeaderboard(offset+i+1, SummonerView.from(summoner, ranksWithRankings, statistics)))
8. response = new LeaderboardPage(page, limit, total, pages, leaderboardSummoners)
if (statistics complete) Redis.set(LEADERBOARD_PAGE, response, ...) // 60s
return ApiResult.ready(response) // 200 with ResponseMetadata(pagination, lastUpdate, refresh:false)
Rank before enrichment: Summoner.ranks holds exact TierDivisionType. After resolveRankings each Rank carries globalRanking + regionRanking Rank.withRankings.
4. resolveRankings — Global + Regional Exact Rank
LeaderboardService.java:216-263:
public static Map<String, Map<GameQueueType, Rank>> resolveRankings(List<RankedSummoner> subjects)
RankedSummoner(String puuid, LeagueShard region, Map<GameQueueType, Rank> ranks)
For each subject with rank.tier != UNRANKED:
globalSegment = CONTEXTUAL_LEADERBOARD_SEGMENT.of(queue, GLOBAL, tier.name()) // LeagueShardUtils.leaderboardScope(null) = "GLOBAL"
regionalSegment= CONTEXTUAL_LEADERBOARD_SEGMENT.of(queue, region.name(), tier.name())
segments.add(GLOBAL + REGIONAL)
globalCounts = counts(queue, null) // getHashLongs(CONTEXTUAL_LEADERBOARD_COUNTS)
regionalCounts= counts(queue, region)
targets.add(RankTarget(puuid, queue, rank, globalCounts, regionalCounts))
keys/members: GLOBAL_SEGMENT + memberBytes, REGIONAL_SEGMENT + memberBytes
Then:
ensureIndexesAsync(segments) // §6, background rebuild if missing
positions = RedisClient.reverseRanks(keys, members) // pipeline ZREVRANK → Long 0-based within tier, null if not member
enriched.put(queue, rank.withRankings(
absoluteRanking(globalCounts, tier, posGlobal),
absoluteRanking(regionalCounts, tier, posRegional)
))
absoluteRanking — Tier-Aware Global Position
LeaderboardService.java:460:
static Long absoluteRanking(Map<TierDivisionType,Long> counts, TierDivisionType tier, Long withinDivision) {
if (counts==null || withinDivision==null || !counts.containsKey(tier)) return null;
long higher = 0;
for (TierDivisionType d : TierDivisionUtils.descendingRankedDivisions()) { // CHALLENGER_I … IRON_IV
if (d == tier) break;
higher += counts.getOrDefault(d, 0L);
}
return higher + withinDivision + 1; // 1-based
}
counts = CONTEXTUAL_LEADERBOARD_COUNTS hash — total players per exact TierDivisionType. withinDivision = ZREVRANK within that tier's ZSET (sorted mmr desc). Summing all higher tiers converts intra-tier rank to global absolute rank across all tiers for that queue+scope.
Example: queue=RANKED_SOLO_5X5, scope=EUW1 counts CHALLENGER_I:120, GRANDMASTER_I:800, MASTER_I:5000, DIAMOND_I:40000, DIAMOND_II:50000…. Player DIAMOND_II with withinDivision=2500 → higher=120+800+5000+40000=45920 → absolute=48421.
If player is not in ZSET or tier count missing → null ranking (not displayed).
RedisClient.java:423 pipeline:
pipeline.zrevrank(keyBytes, memberBytes) // O(log N)
pipeline.zcount(keyBytes, nextUp(score), +INF) // for records, not leaderboard
5. Counts & Total
LeaderboardService.java:311 findLeaderboardCount:
cacheKey = LEADERBOARD_COUNT.of(queue, region, rankKey, roleKey, otpKey)
cached = Redis.getLong(cacheKey) // 60s TEMPORARY_TTL via setCached
if hit return
synchronized(lock per cacheKey) {
if claim(LEADERBOARD_COUNT_LOCK, "1", ...) // SET NX EX 60s atomic
aggregate = (role==null && otp==null) ? MongoDB.findLeaderboardAggregateCount(rank,queue,region) : null
total = aggregate ?? MongoDB.findLeaderboardCount(rank,queue,region,role,otp) // countDocuments
Redis.setCached(cacheKey, total, 60)
}
findLeaderboardAggregateCount reads leaderboard_aggregates precomputed doc; fallback countDocuments on competitive.
Page + count separation avoids expensive facet.
6. Index Lifecycle
applyIndexChange — Incremental on Upsert
Called from CompetitiveService after MongoDB.upsertCompetitiveEntry LeaderboardService.java:368:
applyIndexChange(previous, -1) // remove old tier if changed
applyIndexChange(next, +1)
scopes = [GLOBAL, region.name()]
for scope:
incrementHashIfPresent(LEADERBOARD_COUNTS, tier.name(), delta) // hincrBy if exists
key = SEGMENT.of(queue, scope, tier)
if (!sortedSetExists(key)) continue // lazy — not yet built
if (delta>0) addSortedSet(key, (member, mmr)) else zrem(member)
if (!isHighElo(tier)) expire(key, 6h) // slide TTL
setHashLong(ACCESS, key, now)
No I/O if segment not yet materialized — avoids thundering herd.
ensureIndexesAsync — On-Demand Materialization
LeaderboardService.java:387 called from resolveRankings:
existing = Redis.existingSortedSets(keys) // pipeline EXISTS
for each segment:
if existing → expire 6h + touch ACCESS; continue
background(ComputeScheduler, PROFILE, "leaderboard-index:"+key, job → rebuildIndex(segment))
Enqueues BACKGROUND job, does not block request — first caller gets null ranking but triggers build.
rebuildIndex — Full Rebuild via Temporary Key
LeaderboardService.java:410:
INDEX_BUILDS.putIfAbsent(key, future) // dedup, others join()
ttl = isHighElo(tier) ? 0 : 6h
buildSortedSet(tmpUUID, key, ttl, 10m, consumer →
MongoDB.forEachCompetitiveRankingSegment(queue, tier, region, entry →
consumer.accept((member(entry.puuid, queue), entry.mmr))
)
)
if built:
addPersistentMember(SEGMENTS, key) // SADD + PERSIST
setHashLong(ACCESS, key, now)
INDEX_BUILDS.remove(key)
RedisClient.java:397 buildSortedSet:
batch 250 zadd(tmp) → expire(tmp, 10m) → exists(tmp) check
rename(tmp, key) // atomic publish
expire(key, ttl) or persist(key) if ttl==0
High elo MASTER_I/GRANDMASTER_I/CHALLENGER_I are permanent (ttl 0 + persist) — always warm.
warmupIndex — Boot & Nightly
LeaderboardService.java:277:
for (queue : leaderboardQueues()) {
replaceCounts(queue, null) // hgetAll -> replaceHash from Mongo
for (region : findCompetitiveRankingRegions(queue)) replaceCounts(queue, region)
for (tier : descendingRankedDivisions() if isHighElo(tier)) {
rebuildIndex(GLOBAL tier)
for (region) rebuildIndex(REGIONAL tier)
}
}
Called on startup warmupIndexAsync background and after rebuildAllAggregates.
replaceCounts MongoDB.findCompetitiveRankCounts(queue, region) → replaceHash.
Observability
LeaderboardService.java:289 indexStatus: iterates SEGMENTS set, sortedSetExists, memoryUsage, ttl, zcard, ACCESS hash, INDEX_BUILDS contains → SegmentStatus.
7. Record Ranking — Same Primitive, Different Key
ProfileRecordService.java:87 uses identical ZSET pattern but per filter+metric:
| Key | Member | Score | Ranking |
|---|---|---|---|
CONTEXTUAL_RECORD_SEGMENT los:ranking:{filterKey}:{metric}:{scope} RedisKey.java:44 | SHA-256(filterKey:metric:puuid)[0..16) ProfileRecordService.java:245 | metric.order().score(value) RecordOrder.java:7 HIGHEST=value, LOWEST=-value | zrevrank position + zcount(nextUp(score)..INF) above → ranking = above+1 ProfileRecordService.java:232 232 |
ensureIndexesAsync / rebuildIndex via forEachProfileRecordRankingSegment + CONTEXTUAL_RANKING_BUILD 10m TTL. applyIndexChange zadd/zrem + expire 6h.
8. End-to-End Diagram
Mongo competitive {puuid, queue, tier, mmr, region}
│ forEachCompetitiveRankingSegment (queue,tier,region)
▼
Redis tmp ZSET (uuid) batch 250 zadd(member=16B, score=mmr)
│ rename tmp → los:leaderboard:{queue}:{scope}:{tier} (persist if highElo else 6h)
▼
App resolveRankings(keys=[GLOBAL,REGIONAL], members=[16B,16B])
│ pipeline ZREVRANK → withinDivision (0-based per tier)
│ + COUNTS hash {tier→count}
▼
absoluteRanking = Σ higherTierCounts + withinDivision + 1 → Rank(globalRanking, regionRanking)
│
▼
GET /api/lol/leaderboard → LeaderboardPage[ SummonerLeaderboard(position=offset+i+1, SummonerView(ranks with global/region)) ]
9. Caching Caveats
- First request for a cold tier misses rankings —
nullranking onRank, background build repairs. Subsequent requests after ~seconds get exact ranks. LEADERBOARD_PAGEcached only whenstatistics completeLeaderboardService.java:122— avoids caching partialSummonerViewwithoutProfileStatistics.- Counts hash miss triggers
replaceCountsbackground; returnsMap.of()first time →absoluteRanking→nulluntil rebuilt.
