IodidDOCUMENTATION

persistence/leaderboard-ranking.mdx

/doc/persistence/leaderboard-ranking

PUBLIC DOCUMENTATION

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.java competitive helpers, docs/architecture/adr/0015-contextual-ranking.md, docs/mongo/13-contextual-ranking.md.

1. The competitive Collection — Single Source of Truth

java
// 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 of SHA-256(puuid + ":" + canonicalQueue) MongoDB.java:competitiveId COMPETITIVE_ID_BYTES=16 Binary — deterministic, no puuid duplication.
  • queue is canonical GameQueueTypeUtils.canonicalQueue — TEAM_BUILDER_RANKED_SOLO → RANKED_SOLO_5X5. Only RANKED_SOLO_5X5 + RANKED_FLEX_SR are indexed leaderboardQueues.
  • mmr = TierDivisionUtils.getMmr(tier, lp) TierDivisionUtils.java:78:
java
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-played LaneType aggregated across all champions for that queue CompetitiveService.java:130. otpChampionId = only if OPTUtils.isOtp true.

Mongo Indexes (operator-managed, not created at runtime)

From docs/mongo/08-query-inventory.md + _archive/11-leaderboard-rank-indexes.md:41:

IndexKeyUse
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_mmr6 variants with primary/otpChampionIdfiltered leaderboard
competitive_contextual_global{queue:1, tier:1, mmr:-1} 13:35global exact ranking
competitive_contextual_regional{queue:1, tier:1, region:1, mmr:-1} 13:40regional exact ranking

Filter builder MongoDB.java:competitiveFilter:

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

RedisKeyPatternTypeTTL
CONTEXTUAL_LEADERBOARD_SEGMENT RedisKey.java:40los:leaderboard:{queue}:{scope}:{tier} e.g. los:leaderboard:RANKED_SOLO_5X5:GLOBAL:DIAMOND_IZSET member=16B competitiveId, score=mmr6h Duration.ofHours(6) — 0 (persist) if isHighElo
CONTEXTUAL_LEADERBOARD_COUNTS 41los:leaderboard:counts:{queue}:{scope}HASH field=tier.name → count0 persistent
CONTEXTUAL_LEADERBOARD_SEGMENTS 42los:leaderboard:segmentsSET of segment keyspersistent
CONTEXTUAL_LEADERBOARD_ACCESS 43los:leaderboard:accessHASH segment → lastAccess millispersistent
CONTEXTUAL_LEADERBOARD_BUILD 47los:leaderboard:building:{uuid}temp ZSET10m
LEADERBOARD_PAGE 35los:leaderboard:page:{rank}:{queue}:{region}:{role}:{otp}:{page}:{limit}STRING LeaderboardPage JSON60s TEMPORARY_TTL
LEADERBOARD_COUNT 36 / LOCK 37los:leaderboard:count:{queue}:{region}:{rank}:{role}:{otp}STRING long / NX lock 60s60s / 1m

Member encoding LeaderboardService.java:479:

java
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:

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

java
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:

java
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:

java
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:

java
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:

java
pipeline.zrevrank(keyBytes, memberBytes) // O(log N)
pipeline.zcount(keyBytes, nextUp(score), +INF) // for records, not leaderboard

5. Counts & Total

LeaderboardService.java:311 findLeaderboardCount:

java
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:

java
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:

java
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:

java
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:

java
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:

java
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:

KeyMemberScoreRanking
CONTEXTUAL_RECORD_SEGMENT los:ranking:{filterKey}:{metric}:{scope} RedisKey.java:44SHA-256(filterKey:metric:puuid)[0..16) ProfileRecordService.java:245metric.order().score(value) RecordOrder.java:7 HIGHEST=value, LOWEST=-valuezrevrank 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

text
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 — null ranking on Rank, background build repairs. Subsequent requests after ~seconds get exact ranks.
  • LEADERBOARD_PAGE cached only when statistics complete LeaderboardService.java:122 — avoids caching partial SummonerView without ProfileStatistics.
  • Counts hash miss triggers replaceCounts background; returns Map.of() first time → absoluteRanking → null until rebuilt.