Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 38 additions & 124 deletions EliteAPI/Features/Leaderboards/Services/LbService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
using EliteAPI.Utilities;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;
using StackExchange.Redis;

namespace EliteAPI.Features.Leaderboards.Services;
Expand Down Expand Up @@ -69,71 +68,10 @@ public class LbService(
ILeaderboardRegistrationService registrationService,
ILogger<LbService> logger,
IConnectionMultiplexer redis,
HybridCache cache,
ILeaderboardCacheMetrics cacheMetrics,
IMemberService memberService,
DataContext context)
: ILbService
{
/// <summary>
/// Cached anchor data for leaderboard rank lookups
/// </summary>
private record CachedAnchor(decimal Score, int EntryId);

/// <summary>
/// Cached anchor data for score-based lookups (stores rank at a score threshold)
/// </summary>
private record CachedAmountAnchor(int Rank);

/// <summary>
/// Get the bucket size for a given rank. Higher ranks get finer granularity.
/// Buckets must be larger than typical upcoming request sizes (100) to have any chance of cache hits.
/// </summary>
private static int GetRankBucket(int atRank) {
return atRank switch {
<= 1000 => 10, // Top 1k: bucket of 10, 100 possible buckets
<= 5000 => 50, // Top 5k: bucket of 50, 80 possible buckets
<= 25000 => 250, // Top 25k: bucket of 250, 80 possible buckets
<= 50000 => 500, // Top 50k: bucket of 500, 50 possible buckets
<= 100000 => 1000, // Top 100k: bucket of 1000, 50 possible buckets
_ => 2500 // 100k+: bucket of 2500
};
}

/// <summary>
/// Round a rank to its bucket boundary
/// </summary>
private static int GetBucketedRank(int atRank) {
var bucket = GetRankBucket(atRank);
if (bucket == 1) return atRank;
return (int)Math.Ceiling((double)atRank / bucket) * bucket;
}

/// <summary>
/// Get the cache TTL for a given rank. Higher ranks get shorter TTLs for fresher data.
/// </summary>
private static HybridCacheEntryOptions GetCacheOptions(int atRank) {
var ttl = atRank switch {
<= 1000 => TimeSpan.FromSeconds(20),
<= 5000 => TimeSpan.FromSeconds(30),
<= 25000 => TimeSpan.FromSeconds(45),
<= 50000 => TimeSpan.FromSeconds(60),
_ => TimeSpan.FromSeconds(120)
};
return new HybridCacheEntryOptions {
Expiration = ttl,
LocalCacheExpiration = TimeSpan.FromSeconds(Math.Min(ttl.TotalSeconds, 15))
};
}

/// <summary>
/// Round upcoming count to standard bucket sizes for better cache hit rates.
/// </summary>
private static int GetBucketedUpcoming(int upcoming) {
// Maximize cache reuse, a switch statmement might be used later
return 100;
}

public async Task<(Leaderboard? lb, ILeaderboardDefinition? definition)> GetLeaderboard(string leaderboardId) {
if (!registrationService.LeaderboardsById.TryGetValue(leaderboardId, out var definition)) return (null, null);

Expand Down Expand Up @@ -1205,34 +1143,18 @@ await memberService.UpdatePlayerIfNeeded(member.PlayerUuid, RequestedResources.P
var usingAtAmount = atAmount is > 0 && (decimal)atAmount > userScore;

if (atRank is > 0 && (anchorRank == -1 || atRank < anchorRank)) {
var bucketedRank = GetBucketedRank(atRank.Value);
var anchorCacheKey =
$"lb:anchor:{leaderboardId}:{gameMode ?? "all"}:{identifier ?? "c"}:{removedFilter}:{bucketedRank}";
var cacheOptions = GetCacheOptions(bucketedRank);

var anchorCacheMiss = false;
var cachedAnchor = await cache.GetOrCreateAsync(anchorCacheKey, async ct => {
anchorCacheMiss = true;
var anchor = await baseQuery
.OrderByDescending(e => e.Score)
.ThenByDescending(e => e.LeaderboardEntryId)
.Skip(bucketedRank - 1)
.Select(e => new CachedAnchor(e.Score, e.LeaderboardEntryId))
.FirstOrDefaultAsync(ct);
return anchor;
}, cacheOptions, cancellationToken: cancellationToken);

if (anchorCacheMiss) {
cacheMetrics.RecordAnchorCacheMiss(leaderboardId, bucketedRank);
}
else {
cacheMetrics.RecordAnchorCacheHit(leaderboardId, bucketedRank);
}

if (cachedAnchor is not null) {
anchorScore = cachedAnchor.Score;
anchorId = cachedAnchor.EntryId;
anchorRank = bucketedRank;
var targetRank = atRank.Value;
var anchorEntry = await baseQuery
.OrderByDescending(e => e.Score)
.ThenByDescending(e => e.LeaderboardEntryId)
.Skip(targetRank - 1)
.Select(e => new { e.Score, e.LeaderboardEntryId })
.FirstOrDefaultAsync(cancellationToken);

if (anchorEntry is not null) {
anchorScore = anchorEntry.Score;
anchorId = anchorEntry.LeaderboardEntryId;
anchorRank = targetRank;
}
}
else if (usingAtAmount && atAmount is not null) {
Expand All @@ -1246,41 +1168,33 @@ await memberService.UpdatePlayerIfNeeded(member.PlayerUuid, RequestedResources.P
var previousPlayers = new List<LeaderboardEntryDto>();

if (upcoming > 0 && !userEntry.IsRemoved) {
var bucketedUpcoming = GetBucketedUpcoming(upcoming.Value);
var upcomingCacheKey =
$"lb:upcoming:{leaderboardId}:{gameMode ?? "all"}:{identifier ?? "c"}:{removedFilter}:{definition.IsMemberLeaderboard()}:{anchorRank}:{bucketedUpcoming}";
var cacheOptions = GetCacheOptions(anchorRank > 0 ? anchorRank : 50000);

var upcomingCacheMiss = false;
var cachedUpcoming = await cache.GetOrCreateAsync(upcomingCacheKey, async ct => {
upcomingCacheMiss = true;
var upcomingQuery = baseQuery
.Where(e => e.Score > anchorScore || (e.Score == anchorScore && e.LeaderboardEntryId > anchorId))
.OrderBy(e => e.Score)
.ThenBy(e => e.LeaderboardEntryId)
.Take(bucketedUpcoming);

var list = definition.IsMemberLeaderboard()
? await upcomingQuery.MapToMemberLeaderboardEntries(includeMeta: false).ToListAsync(ct)
: await upcomingQuery.MapToProfileLeaderboardEntries(removedFilter).ToListAsync(ct);

return list.Select((entry, i) => entry.ToDtoWithRank(anchorRank - i - 1)).ToList();
}, cacheOptions, cancellationToken: cancellationToken);

if (upcomingCacheMiss) {
cacheMetrics.RecordUpcomingCacheMiss(leaderboardId, anchorRank > 0 ? anchorRank : 50000);
}
else {
cacheMetrics.RecordUpcomingCacheHit(leaderboardId, anchorRank > 0 ? anchorRank : 50000);
var fetchLimit = usingAtAmount ? upcoming.Value : upcoming.Value + 1;
var upcomingQuery = baseQuery
.Where(e => e.Score > anchorScore || (e.Score == anchorScore && e.LeaderboardEntryId > anchorId));
if (usingAtAmount && atAmount is not null) {
var atAmountDecimal = (decimal)atAmount.Value;
upcomingQuery = upcomingQuery.Where(e => e.Score > atAmountDecimal);
}

if (usingAtAmount) {
upcomingPlayers = cachedUpcoming.Where(e => atAmount < e.Amount && e.Uuid != resourceId)
.Take(upcoming.Value).ToList();
}
else {
upcomingPlayers = cachedUpcoming.Where(e => e.Uuid != resourceId).Take(upcoming.Value).ToList();
}
var upcomingList = definition.IsMemberLeaderboard()
? await upcomingQuery
.OrderBy(e => e.Score)
.ThenBy(e => e.LeaderboardEntryId)
.Take(fetchLimit)
.MapToMemberLeaderboardEntries(includeMeta: false)
.ToListAsync(cancellationToken)
: await upcomingQuery
.OrderBy(e => e.Score)
.ThenBy(e => e.LeaderboardEntryId)
.Take(fetchLimit)
.MapToProfileLeaderboardEntries(removedFilter)
.ToListAsync(cancellationToken);

upcomingPlayers = upcomingList
.Select((entry, i) => entry.ToDtoWithRank(anchorRank - i - 1))
.Where(e => e.Uuid != resourceId)
.Take(upcoming.Value)
.ToList();

var next = upcomingPlayers.FirstOrDefault();
if (next is not null && next.Rank > 0) {
Expand Down Expand Up @@ -1597,4 +1511,4 @@ public class LeaderboardEntry
public class LeaderboardEntryWithRank : LeaderboardEntry
{
public int Rank { get; init; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,13 @@ namespace EliteAPI.Features.Leaderboards.Services;

public interface ILeaderboardCacheMetrics
{
void RecordAnchorCacheHit(string leaderboardId, int rankTier);
void RecordAnchorCacheMiss(string leaderboardId, int rankTier);
void RecordUpcomingCacheHit(string leaderboardId, int rankTier);
void RecordUpcomingCacheMiss(string leaderboardId, int rankTier);
void RecordBatchProcessed(int count, double durationMs);
void RecordQueueDepth(int depth);
void RecordUpdateDropped();
}

public class LeaderboardCacheMetrics : ILeaderboardCacheMetrics
{
private readonly Counter<long> _anchorCacheHits;
private readonly Counter<long> _anchorCacheMisses;
private readonly Counter<long> _upcomingCacheHits;
private readonly Counter<long> _upcomingCacheMisses;
private readonly Counter<long> _batchesProcessed;
private readonly Histogram<double> _batchDuration;
private readonly Counter<long> _entriesProcessed;
Expand All @@ -27,23 +19,7 @@ public class LeaderboardCacheMetrics : ILeaderboardCacheMetrics

public LeaderboardCacheMetrics(IMeterFactory meterFactory) {
var meter = meterFactory.Create("eliteapi.leaderboard");

_anchorCacheHits = meter.CreateCounter<long>(
"eliteapi.leaderboard.anchor_cache_hits",
description: "Number of anchor cache hits for leaderboard rank lookups");

_anchorCacheMisses = meter.CreateCounter<long>(
"eliteapi.leaderboard.anchor_cache_misses",
description: "Number of anchor cache misses for leaderboard rank lookups");

_upcomingCacheHits = meter.CreateCounter<long>(
"eliteapi.leaderboard.upcoming_cache_hits",
description: "Number of upcoming players cache hits");

_upcomingCacheMisses = meter.CreateCounter<long>(
"eliteapi.leaderboard.upcoming_cache_misses",
description: "Number of upcoming players cache misses");


_batchesProcessed = meter.CreateCounter<long>(
"eliteapi.leaderboard.batches_processed",
description: "Number of leaderboard update batches processed");
Expand All @@ -67,30 +43,6 @@ public LeaderboardCacheMetrics(IMeterFactory meterFactory) {
description: "Current number of pending leaderboard updates in the queue");
}

public void RecordAnchorCacheHit(string leaderboardId, int rankTier) {
_anchorCacheHits.Add(1,
new KeyValuePair<string, object?>("leaderboard", leaderboardId),
new KeyValuePair<string, object?>("rank_tier", GetRankTierLabel(rankTier)));
}

public void RecordAnchorCacheMiss(string leaderboardId, int rankTier) {
_anchorCacheMisses.Add(1,
new KeyValuePair<string, object?>("leaderboard", leaderboardId),
new KeyValuePair<string, object?>("rank_tier", GetRankTierLabel(rankTier)));
}

public void RecordUpcomingCacheHit(string leaderboardId, int rankTier) {
_upcomingCacheHits.Add(1,
new KeyValuePair<string, object?>("leaderboard", leaderboardId),
new KeyValuePair<string, object?>("rank_tier", GetRankTierLabel(rankTier)));
}

public void RecordUpcomingCacheMiss(string leaderboardId, int rankTier) {
_upcomingCacheMisses.Add(1,
new KeyValuePair<string, object?>("leaderboard", leaderboardId),
new KeyValuePair<string, object?>("rank_tier", GetRankTierLabel(rankTier)));
}

public void RecordBatchProcessed(int count, double durationMs) {
_batchesProcessed.Add(1);
_batchDuration.Record(durationMs);
Expand All @@ -104,15 +56,4 @@ public void RecordQueueDepth(int depth) {
public void RecordUpdateDropped() {
_updatesDropped.Add(1);
}

private static string GetRankTierLabel(int rank) {
return rank switch {
<= 1000 => "top_1k",
<= 5000 => "top_5k",
<= 25000 => "top_25k",
<= 50000 => "top_50k",
<= 100000 => "top_100k",
_ => "100k_plus"
};
}
}
Loading
Loading