Skip to content

Commit 128f8a4

Browse files
authored
Merge pull request #3 from bdach/revival
Add pathway to query global rank via `scores` tables
2 parents 0f396e9 + 9599e85 commit 128f8a4

7 files changed

Lines changed: 274 additions & 28 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2+
// See the LICENCE file in the repository root for full licence text.
3+
4+
namespace GlobalRankLookupCache.Controllers
5+
{
6+
public record BeatmapRankLookupResult(int Position, int Total, bool Accurate);
7+
}

GlobalRankLookupCache/Controllers/BeatmapItem.cs renamed to GlobalRankLookupCache/Controllers/LegacyBeatmapItem.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
namespace GlobalRankLookupCache.Controllers
88
{
9-
internal class BeatmapItem
9+
internal class LegacyBeatmapItem
1010
{
1111
public List<int> Scores;
1212

@@ -18,15 +18,15 @@ internal class BeatmapItem
1818

1919
private bool isQualified;
2020

21-
private TaskCompletionSource<bool> populated = new TaskCompletionSource<bool>();
21+
private readonly TaskCompletionSource<bool> populated = new TaskCompletionSource<bool>();
2222

23-
public BeatmapItem(int beatmapId, string highScoresTable)
23+
public LegacyBeatmapItem(int beatmapId, string highScoresTable)
2424
{
2525
this.beatmapId = beatmapId;
2626
this.highScoresTable = highScoresTable;
2727
}
2828

29-
public async Task<(int position, int total, bool accurate)> Lookup(int score)
29+
public async Task<BeatmapRankLookupResult> Lookup(int score)
3030
{
3131
Interlocked.Increment(ref requestsSinceLastPopulation);
3232

@@ -55,14 +55,14 @@ public BeatmapItem(int beatmapId, string highScoresTable)
5555
int accurateTotal = (int)(long)(await cmd.ExecuteScalarAsync())!;
5656

5757
int accuratePosition = await getAccuratePosition(db, score);
58-
return (accuratePosition, accurateTotal, true);
58+
return new BeatmapRankLookupResult(accuratePosition, accurateTotal, true);
5959
}
6060
else
6161
{
6262
cmd.CommandText = $"select count(*) from {highScoresTable} where beatmap_id = {beatmapId} and score > {score} and hidden = 0";
6363
int roughPosition = (int)(long)(await cmd.ExecuteScalarAsync())!;
6464

65-
return (roughPosition, roughTotal, false);
65+
return new BeatmapRankLookupResult(roughPosition, roughTotal, false);
6666
}
6767
}
6868
}
@@ -87,7 +87,7 @@ public BeatmapItem(int beatmapId, string highScoresTable)
8787
position = await getAccuratePosition(db, score);
8888
}
8989

90-
return (position, scores.Count, true);
90+
return new BeatmapRankLookupResult(position, scores.Count, true);
9191
}
9292

9393
private async Task<int> getAccuratePosition(MySqlConnection db, int score)

GlobalRankLookupCache/Controllers/BeatmapRankCacheCollection.cs renamed to GlobalRankLookupCache/Controllers/LegacyBeatmapRankCache.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
namespace GlobalRankLookupCache.Controllers
66
{
7-
internal class BeatmapRankCacheCollection
7+
internal class LegacyBeatmapRankCache
88
{
99
private readonly string highScoresTable;
1010

@@ -15,7 +15,7 @@ internal class BeatmapRankCacheCollection
1515

1616
public long Count => beatmapScoresLookup.Count;
1717

18-
public BeatmapRankCacheCollection(string highScoresTable)
18+
public LegacyBeatmapRankCache(string highScoresTable)
1919
{
2020
this.highScoresTable = highScoresTable;
2121
}
@@ -34,11 +34,11 @@ public BeatmapRankCacheCollection(string highScoresTable)
3434
//
3535
// public bool Clear(in int beatmapId) => beatmapScoresLookup.TryRemove(beatmapId, out var _);
3636

37-
public Task<(int position, int total, bool accurate)> Lookup(int beatmapId, in int score)
37+
public Task<BeatmapRankLookupResult> Lookup(int beatmapId, in int score)
3838
{
3939
return beatmapScoresLookup.GetOrCreate(beatmapId, e =>
4040
{
41-
var item = new BeatmapItem(beatmapId, highScoresTable);
41+
var item = new LegacyBeatmapItem(beatmapId, highScoresTable);
4242

4343
e.SetSlidingExpiration(TimeSpan.FromDays(1));
4444
e.Size = 1;

GlobalRankLookupCache/Controllers/RankLookupController.cs

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,20 @@ public class RankLookupController : ControllerBase
1616

1717
private static long lastReport = DateTimeOffset.Now.ToUnixTimeSeconds();
1818

19-
private static readonly BeatmapRankCacheCollection[] beatmap_rank_cache =
19+
private static readonly LegacyBeatmapRankCache[] legacy_rank_caches =
2020
{
21-
new BeatmapRankCacheCollection("osu_scores_high"),
22-
new BeatmapRankCacheCollection("osu_scores_taiko_high"),
23-
new BeatmapRankCacheCollection("osu_scores_fruits_high"),
24-
new BeatmapRankCacheCollection("osu_scores_mania_high")
21+
new LegacyBeatmapRankCache("osu_scores_high"),
22+
new LegacyBeatmapRankCache("osu_scores_taiko_high"),
23+
new LegacyBeatmapRankCache("osu_scores_fruits_high"),
24+
new LegacyBeatmapRankCache("osu_scores_mania_high")
2525
};
2626

27+
private static readonly ScoresBeatmapRankCache scores_rank_cache = new ScoresBeatmapRankCache();
28+
29+
private static readonly bool use_new_tables = bool.TryParse(Environment.GetEnvironmentVariable("USE_NEW_TABLES"), out bool parsed)
30+
? parsed
31+
: throw new InvalidOperationException("USE_NEW_TABLES environment variable must be set to either `[fF]alse` or `[tT]rue`.");
32+
2733
[HttpGet]
2834
public async Task<IActionResult> Get(int rulesetId, int beatmapId, int score)
2935
{
@@ -35,8 +41,10 @@ public async Task<IActionResult> Get(int rulesetId, int beatmapId, int score)
3541
output();
3642
}
3743

38-
(int position, int total, bool accurate) = await beatmap_rank_cache[rulesetId].Lookup(beatmapId, score);
39-
return Content($"{position},{total},{accurate}");
44+
var result = use_new_tables
45+
? await scores_rank_cache.Lookup(beatmapId, rulesetId, score)
46+
: await legacy_rank_caches[rulesetId].Lookup(beatmapId, score);
47+
return Content($"{result.Position},{result.Total},{result.Accurate}");
4048
}
4149

4250
private void output()
@@ -50,7 +58,7 @@ private void output()
5058
long memory = GC.GetTotalMemory(false);
5159

5260
Console.WriteLine();
53-
Console.WriteLine($"mem:{memory / 1048576:N0} MB beatmaps:{beatmap_rank_cache.Sum(c => c.Count)} hr:{hitRate:P0} h:{hits:N0} m:{misses:N0} p:{populations:N0}");
61+
Console.WriteLine($"mem:{memory / 1048576:N0} MB beatmaps:{legacy_rank_caches.Sum(c => c.Count) + scores_rank_cache.Count} hr:{hitRate:P0} h:{hits:N0} m:{misses:N0} p:{populations:N0}");
5462
}
5563
}
5664
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using MySqlConnector;
6+
7+
namespace GlobalRankLookupCache.Controllers
8+
{
9+
internal class ScoresBeatmapItem
10+
{
11+
private List<int> scores;
12+
13+
private readonly int beatmapId;
14+
private readonly int rulesetId;
15+
16+
private DateTimeOffset lastPopulation;
17+
private int requestsSinceLastPopulation;
18+
19+
private bool isQualified;
20+
21+
private readonly TaskCompletionSource<bool> populated = new TaskCompletionSource<bool>();
22+
23+
private string whereCondition => $"`beatmap_id` = {beatmapId} AND `ruleset_id` = {rulesetId} AND `preserve` = 1 AND `ranked` = 1";
24+
25+
public ScoresBeatmapItem(int beatmapId, int rulesetId)
26+
{
27+
this.beatmapId = beatmapId;
28+
this.rulesetId = rulesetId;
29+
}
30+
31+
public async Task<BeatmapRankLookupResult> Lookup(int score)
32+
{
33+
Interlocked.Increment(ref requestsSinceLastPopulation);
34+
35+
if (!populated.Task.IsCompleted && population_tasks_semaphore.CurrentCount > 0)
36+
await Task.WhenAny(Task.Delay(1000), Task.Run(repopulateScores));
37+
38+
// may change due to re-population; use a local copy
39+
List<int> localScores = scores;
40+
41+
if (!populated.Task.IsCompleted)
42+
{
43+
// do quick lookup
44+
using (var db = await Program.GetDatabaseConnection())
45+
using (var cmd = db.CreateCommand())
46+
{
47+
Interlocked.Increment(ref RankLookupController.Misses);
48+
49+
cmd.CommandTimeout = 10;
50+
cmd.CommandText = $"SELECT COUNT(*) FROM `scores` WHERE {whereCondition}";
51+
52+
int roughTotal = (int)(long)(await cmd.ExecuteScalarAsync())!;
53+
54+
if (roughTotal < 2000)
55+
{
56+
cmd.CommandText = $"SELECT COUNT(DISTINCT `user_id`) from `scores` WHERE {whereCondition}";
57+
int accurateTotal = (int)(long)(await cmd.ExecuteScalarAsync())!;
58+
59+
int accuratePosition = await getAccuratePosition(db, score);
60+
return new BeatmapRankLookupResult(accuratePosition, accurateTotal, true);
61+
}
62+
63+
cmd.CommandText = $"SELECT COUNT(*) from `scores` WHERE {whereCondition} AND `total_score` > {score}";
64+
int roughPosition = (int)(long)(await cmd.ExecuteScalarAsync())!;
65+
66+
return new BeatmapRankLookupResult(roughPosition, roughTotal, false);
67+
}
68+
}
69+
70+
// For qualified beatmaps, ensure that transitions to ranked state are handled almost immediately.
71+
// Generally qualified beatmaps should not have many scores, so the overhead here should not be too important.
72+
if (isQualified && (DateTime.Now - lastPopulation).TotalSeconds > 60)
73+
_ = Task.Run(repopulateScores);
74+
// Re-populate if enough time has passed and enough requests were made.
75+
else if ((DateTime.Now - lastPopulation).TotalSeconds > localScores.Count && (localScores.Count < 1000 || requestsSinceLastPopulation >= 5))
76+
_ = Task.Run(repopulateScores);
77+
78+
Interlocked.Increment(ref RankLookupController.Hits);
79+
int result = localScores.BinarySearch(score + 1);
80+
int position = (localScores.Count - (result < 0 ? ~result : result));
81+
82+
// A new top score was achieved.
83+
// To ensure medals and profiles are updated accurately, require a re-fetch at this point.
84+
if (position < 500)
85+
{
86+
using (var db = await Program.GetDatabaseConnection())
87+
position = await getAccuratePosition(db, score);
88+
}
89+
90+
return new BeatmapRankLookupResult(position, localScores.Count, true);
91+
}
92+
93+
private async Task<int> getAccuratePosition(MySqlConnection db, int score)
94+
{
95+
using (var cmd = db.CreateCommand())
96+
{
97+
cmd.CommandTimeout = 10;
98+
cmd.CommandText = $"SELECT COUNT(DISTINCT user_id) FROM `scores` WHERE {whereCondition} AND `total_score` > {score}";
99+
return (int)(long)(await cmd.ExecuteScalarAsync())!;
100+
}
101+
}
102+
103+
private readonly SemaphoreSlim populationSemaphore = new SemaphoreSlim(1);
104+
105+
private static readonly SemaphoreSlim population_tasks_semaphore = new SemaphoreSlim(10);
106+
107+
private async Task repopulateScores()
108+
{
109+
// Drop excess requests. If they are common they will arrive again.
110+
if (population_tasks_semaphore.CurrentCount == 0)
111+
return;
112+
113+
if (!await populationSemaphore.WaitAsync(100))
114+
return;
115+
116+
await population_tasks_semaphore.WaitAsync();
117+
118+
var newScores = new List<int>();
119+
120+
bool isRepopulate = this.scores != null;
121+
122+
try
123+
{
124+
using (var db = await Program.GetDatabaseConnection())
125+
using (var cmd = db.CreateCommand())
126+
{
127+
cmd.CommandTimeout = 600;
128+
129+
cmd.CommandText = $"SELECT COUNT(*) FROM `scores` WHERE {whereCondition}";
130+
int liveCount = (int)(long)(await cmd.ExecuteScalarAsync())!;
131+
132+
cmd.CommandText = $"SELECT `approved` FROM `osu_beatmaps` WHERE `beatmap_id` = {beatmapId}";
133+
isQualified = (sbyte)(await cmd.ExecuteScalarAsync())! == 3;
134+
135+
// Check whether things actually changed enough to matter. If not, skip this update.
136+
// Of note, if scores *reduced* we should update immediately. This may be a foul play score removed from the header of the leaderboard.
137+
//
138+
// Note that this might not work great if a user improves a score.
139+
if (isRepopulate && liveCount >= newScores.Count && liveCount - newScores.Count < 10)
140+
{
141+
Console.Write("-");
142+
}
143+
else
144+
{
145+
var users = new HashSet<int>();
146+
147+
cmd.CommandText = $"SELECT `user_id`, `total_score` FROM `scores` WHERE {whereCondition}";
148+
149+
using (var reader = await cmd.ExecuteReaderAsync())
150+
{
151+
while (reader.Read())
152+
{
153+
int userId = reader.GetInt32(0);
154+
int score = reader.GetInt32(1);
155+
156+
// we want one score per user at most
157+
if (users.Add(userId))
158+
newScores.Add(score);
159+
}
160+
}
161+
162+
newScores.Reverse();
163+
scores = newScores;
164+
165+
Console.Write(isRepopulate ? "R" : "P");
166+
}
167+
}
168+
169+
lastPopulation = DateTimeOffset.Now;
170+
requestsSinceLastPopulation = 0;
171+
Interlocked.Increment(ref RankLookupController.Populations);
172+
173+
if (!populated.Task.IsCompleted)
174+
populated.SetResult(true);
175+
}
176+
catch (Exception e)
177+
{
178+
Console.WriteLine();
179+
Console.WriteLine(e.ToString());
180+
// will retry next lookup
181+
}
182+
183+
population_tasks_semaphore.Release();
184+
populationSemaphore.Release();
185+
}
186+
}
187+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using System;
2+
using System.Threading.Tasks;
3+
using Microsoft.Extensions.Caching.Memory;
4+
5+
namespace GlobalRankLookupCache.Controllers
6+
{
7+
internal class ScoresBeatmapRankCache
8+
{
9+
private readonly MemoryCache beatmapScoresLookup = new MemoryCache(new MemoryCacheOptions
10+
{
11+
SizeLimit = 768000,
12+
});
13+
14+
public long Count => beatmapScoresLookup.Count;
15+
16+
public Task<BeatmapRankLookupResult> Lookup(int beatmapId, int rulesetId, int score)
17+
{
18+
return beatmapScoresLookup.GetOrCreate(beatmapId, e =>
19+
{
20+
var item = new ScoresBeatmapItem(beatmapId, rulesetId);
21+
22+
e.SetSlidingExpiration(TimeSpan.FromDays(1));
23+
e.Size = 1;
24+
e.Value = item;
25+
26+
return item;
27+
}).Lookup(score);
28+
}
29+
}
30+
}

0 commit comments

Comments
 (0)