|
| 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 | +} |
0 commit comments