Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add caching and total room score tracking for multiplayer playlist item statistics #242

Merged
merged 15 commits into from
Jul 26, 2024
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
2 changes: 1 addition & 1 deletion SampleMultiplayerClient/SampleMultiplayerClient.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="8.0.2" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
<PackageReference Include="ppy.osu.Game" Version="2024.718.0" />
<PackageReference Include="ppy.osu.Game" Version="2024.726.1" />
</ItemGroup>

</Project>
2 changes: 1 addition & 1 deletion SampleSpectatorClient/SampleSpectatorClient.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="8.0.2" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
<PackageReference Include="ppy.osu.Game" Version="2024.718.0" />
<PackageReference Include="ppy.osu.Game" Version="2024.726.1" />
</ItemGroup>

</Project>
5 changes: 1 addition & 4 deletions osu.Server.Spectator.Tests/MetadataHubTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Logging;
using Moq;
using osu.Game.Online.Metadata;
Expand All @@ -32,7 +30,6 @@ public class MetadataHubTest

public MetadataHubTest()
{
var cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));
userStates = new EntityStore<MetadataClientState>();

var mockDatabase = new Mock<IDatabaseAccess>();
Expand All @@ -44,7 +41,7 @@ public MetadataHubTest()

hub = new MetadataHub(
loggerFactoryMock.Object,
cache,
new MemoryCache(new MemoryCacheOptions()),
userStates,
databaseFactory.Object,
new Mock<IDailyChallengeUpdater>().Object,
Expand Down
4 changes: 0 additions & 4 deletions osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Logging;
using Moq;
using osu.Game.Beatmaps;
Expand Down Expand Up @@ -135,7 +132,6 @@ protected MultiplayerTest()

Hub = new TestMultiplayerHub(
loggerFactoryMock.Object,
new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())),
Rooms,
UserStates,
DatabaseFactory.Object,
Expand Down
4 changes: 1 addition & 3 deletions osu.Server.Spectator.Tests/Multiplayer/TestMultiplayerHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.

using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using osu.Server.Spectator.Database;
using osu.Server.Spectator.Entities;
Expand All @@ -16,13 +15,12 @@ public class TestMultiplayerHub : MultiplayerHub

public TestMultiplayerHub(
ILoggerFactory loggerFactory,
IDistributedCache cache,
EntityStore<ServerMultiplayerRoom> rooms,
EntityStore<MultiplayerClientState> users,
IDatabaseFactory databaseFactory,
ChatFilters chatFilters,
IHubContext<MultiplayerHub> hubContext)
: base(loggerFactory, cache, rooms, users, databaseFactory, chatFilters, hubContext)
: base(loggerFactory, rooms, users, databaseFactory, chatFilters, hubContext)
{
}

Expand Down
8 changes: 1 addition & 7 deletions osu.Server.Spectator.Tests/SpectatorHubTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using osu.Game.Beatmaps;
using osu.Game.Online.API.Requests.Responses;
Expand Down Expand Up @@ -41,9 +38,6 @@ public class SpectatorHubTest

public SpectatorHubTest()
{
// not used for now, but left here for potential future usage.
MemoryDistributedCache cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));

var clientStates = new EntityStore<SpectatorClientState>();

mockDatabase = new Mock<IDatabaseAccess>();
Expand All @@ -68,7 +62,7 @@ public SpectatorHubTest()

var mockScoreProcessedSubscriber = new Mock<IScoreProcessedSubscriber>();

hub = new SpectatorHub(loggerFactory.Object, cache, clientStates, databaseFactory.Object, scoreUploader, mockScoreProcessedSubscriber.Object);
hub = new SpectatorHub(loggerFactory.Object, clientStates, databaseFactory.Object, scoreUploader, mockScoreProcessedSubscriber.Object);
}

[Fact]
Expand Down
6 changes: 3 additions & 3 deletions osu.Server.Spectator.Tests/StatefulUserHubTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public StatefulUserHubTest()
MemoryDistributedCache cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));

userStates = new EntityStore<ClientState>();
hub = new TestStatefulHub(loggerFactoryMock.Object, cache, userStates);
hub = new TestStatefulHub(loggerFactoryMock.Object, userStates);

mockContext = new Mock<HubCallerContext>();
mockContext.Setup(context => context.UserIdentifier).Returns(user_id.ToString());
Expand Down Expand Up @@ -113,8 +113,8 @@ private void setNewConnectionId(string? connectionId = null) =>

private class TestStatefulHub : StatefulUserHub<IStatefulUserHubClient, ClientState>
{
public TestStatefulHub(ILoggerFactory loggerFactory, IDistributedCache cache, EntityStore<ClientState> userStates)
: base(loggerFactory, cache, userStates)
public TestStatefulHub(ILoggerFactory loggerFactory, EntityStore<ClientState> userStates)
: base(loggerFactory, userStates)
{
}

Expand Down
36 changes: 9 additions & 27 deletions osu.Server.Spectator/Database/DatabaseAccess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -415,37 +415,19 @@ public async Task<IEnumerable<multiplayer_room>> GetActiveDailyChallengeRoomsAsy
new { scoreId = scoreId });
}

public async Task<MultiplayerPlaylistItemStats[]> GetMultiplayerRoomStatsAsync(long roomId)
public async Task<IEnumerable<SoloScore>> GetPassingScoresForPlaylistItem(long playlistItemId, ulong afterScoreId = 0)
{
var connection = await getConnectionAsync();

long[] playlistItemIds = (await GetAllPlaylistItemsAsync(roomId)).Select(item => item.id).ToArray();
var result = new MultiplayerPlaylistItemStats[playlistItemIds.Length];

for (int i = 0; i < playlistItemIds.Length; ++i)
{
long[] totalScores = (await connection.QueryAsync<long>(
"SELECT `scores`.`total_score` FROM `scores` "
+ "JOIN `multiplayer_score_links` ON `multiplayer_score_links`.`score_id` = `scores`.`id` "
+ "WHERE `passed` = 1 AND `multiplayer_score_links`.`playlist_item_id` = @playlistItemId", new
{
playlistItemId = playlistItemIds[i]
})).ToArray();

var totals = totalScores.GroupBy(score => (int)Math.Clamp(Math.Floor((float)score / 100000), 0, MultiplayerPlaylistItemStats.TOTAL_SCORE_DISTRIBUTION_BINS - 1))
.OrderBy(grp => grp.Key)
.ToDictionary(grp => grp.Key, grp => grp.LongCount());

var stats = new MultiplayerPlaylistItemStats
return (await connection.QueryAsync<SoloScore>(
"SELECT `scores`.`id`, `scores`.`total_score` FROM `scores` "
+ "JOIN `multiplayer_score_links` ON `multiplayer_score_links`.`score_id` = `scores`.`id` "
+ "WHERE `passed` = 1 AND `multiplayer_score_links`.`playlist_item_id` = @playlistItemId "
+ "AND `multiplayer_score_links`.`score_id` > @afterScoreId", new
{
PlaylistItemID = playlistItemIds[i],
TotalScoreDistribution = Enumerable.Range(0, MultiplayerPlaylistItemStats.TOTAL_SCORE_DISTRIBUTION_BINS).Select(i => totals.GetValueOrDefault(i)).ToArray(),
};

result[i] = stats;
}

return result;
playlistItemId = playlistItemId,
afterScoreId = afterScoreId,
}));
}

public async Task<multiplayer_scores_high?> GetUserBestScoreAsync(long playlistItemId, int userId)
Expand Down
7 changes: 5 additions & 2 deletions osu.Server.Spectator/Database/IDatabaseAccess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,12 @@ public interface IDatabaseAccess : IDisposable
Task<(long roomID, long playlistItemID)?> GetMultiplayerRoomIdForScoreAsync(long scoreId);

/// <summary>
/// Returns <see cref="MultiplayerPlaylistItemStats"/> for all playlist items in the room with the given <paramref name="roomId"/>.
/// Retrieve all passing scores for a specified playlist item.
/// </summary>
Task<MultiplayerPlaylistItemStats[]> GetMultiplayerRoomStatsAsync(long roomId);
/// <param name="playlistItemId">The playlist item.</param>
/// <param name="afterScoreId">An optional score ID to only fetch newer scores.</param>
/// <returns></returns>
Task<IEnumerable<SoloScore>> GetPassingScoresForPlaylistItem(long playlistItemId, ulong afterScoreId = 0);

/// <summary>
/// Returns the best score of user with <paramref name="userId"/> on the playlist item with <paramref name="playlistItemId"/>.
Expand Down
67 changes: 63 additions & 4 deletions osu.Server.Spectator/Hubs/Metadata/MetadataHub.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Primitives;
using Microsoft.Extensions.Logging;
using osu.Game.Online;
using osu.Game.Online.Metadata;
using osu.Game.Users;
using osu.Server.Spectator.Database;
using osu.Server.Spectator.Database.Models;
using osu.Server.Spectator.Entities;
using osu.Server.Spectator.Extensions;
using osu.Server.Spectator.Hubs.Spectator;
Expand All @@ -19,6 +23,7 @@ namespace osu.Server.Spectator.Hubs.Metadata
{
public class MetadataHub : StatefulUserHub<IMetadataClient, MetadataClientState>, IMetadataServer
{
private readonly IMemoryCache cache;
private readonly IDatabaseFactory databaseFactory;
private readonly IDailyChallengeUpdater dailyChallengeUpdater;
private readonly IScoreProcessedSubscriber scoreProcessedSubscriber;
Expand All @@ -29,13 +34,14 @@ public class MetadataHub : StatefulUserHub<IMetadataClient, MetadataClientState>

public MetadataHub(
ILoggerFactory loggerFactory,
IDistributedCache cache,
IMemoryCache cache,
EntityStore<MetadataClientState> userStates,
IDatabaseFactory databaseFactory,
IDailyChallengeUpdater dailyChallengeUpdater,
IScoreProcessedSubscriber scoreProcessedSubscriber)
: base(loggerFactory, cache, userStates)
: base(loggerFactory, userStates)
{
this.cache = cache;
this.databaseFactory = databaseFactory;
this.dailyChallengeUpdater = dailyChallengeUpdater;
this.scoreProcessedSubscriber = scoreProcessedSubscriber;
Expand Down Expand Up @@ -107,13 +113,66 @@ public async Task UpdateStatus(UserStatus? status)
}
}

private static readonly object update_stats_lock = new object();

public async Task<MultiplayerPlaylistItemStats[]> BeginWatchingMultiplayerRoom(long id)
{
await Groups.AddToGroupAsync(Context.ConnectionId, MultiplayerRoomWatchersGroup(id));
await scoreProcessedSubscriber.RegisterForMultiplayerRoomAsync(Context.GetUserId(), id);

using var db = databaseFactory.GetInstance();
return await db.GetMultiplayerRoomStatsAsync(id);

MultiplayerRoomStats stats = (await cache.GetOrCreateAsync<MultiplayerRoomStats>(id.ToString(), e =>
{
e.SlidingExpiration = TimeSpan.FromHours(24);
return Task.FromResult(new MultiplayerRoomStats { RoomID = id });
}))!;

await updateMultiplayerRoomStatsAsync(db, stats);

// Outside of locking so may be mid-update, but that's fine we don't need perfectly accurate for client-side.
return stats.PlaylistItemStats.Values.ToArray();
}

private async Task updateMultiplayerRoomStatsAsync(IDatabaseAccess db, MultiplayerRoomStats stats)
{
long[] playlistItemIds = (await db.GetAllPlaylistItemsAsync(stats.RoomID)).Select(item => item.id).ToArray();

for (int i = 0; i < playlistItemIds.Length; ++i)
{
long itemId = playlistItemIds[i];

if (!stats.PlaylistItemStats.TryGetValue(itemId, out var itemStats))
stats.PlaylistItemStats[itemId] = itemStats = new MultiplayerPlaylistItemStats { PlaylistItemID = itemId, };

ulong lastProcessed = itemStats.LastProcessedScoreID;

SoloScore[] scores = (await db.GetPassingScoresForPlaylistItem(itemId, itemStats.LastProcessedScoreID)).ToArray();

if (scores.Length == 0)
return;

// Lock globally for simplicity.
// If it ever becomes an issue we can move to per-item locking or something more complex.
lock (update_stats_lock)
bdach marked this conversation as resolved.
Show resolved Hide resolved
{
// check whether last id has changed since database query completed. if it did, this means another run would have updated the stats.
// for simplicity, just skip the update and wait for the next.
if (lastProcessed == itemStats.LastProcessedScoreID)
{
Dictionary<int, long> totals = scores
.Select(s => s.total_score)
.GroupBy(score => (int)Math.Clamp(Math.Floor((float)score / 100000), 0, MultiplayerPlaylistItemStats.TOTAL_SCORE_DISTRIBUTION_BINS - 1))
.OrderBy(grp => grp.Key)
.ToDictionary(grp => grp.Key, grp => grp.LongCount());

itemStats.CumulativeScore += scores.Sum(s => s.total_score);
for (int j = 0; j < MultiplayerPlaylistItemStats.TOTAL_SCORE_DISTRIBUTION_BINS; j++)
itemStats.TotalScoreDistribution[j] += totals.GetValueOrDefault(j);
itemStats.LastProcessedScoreID = scores.Max(s => s.id);
}
}
}
}

public async Task EndWatchingMultiplayerRoom(long id)
Expand Down
15 changes: 15 additions & 0 deletions osu.Server.Spectator/Hubs/Metadata/MultiplayerRoomStats.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System.Collections.Concurrent;
using osu.Game.Online.Metadata;

namespace osu.Server.Spectator.Hubs.Metadata
{
public class MultiplayerRoomStats
{
public long RoomID { get; init; }

public readonly ConcurrentDictionary<long, MultiplayerPlaylistItemStats> PlaylistItemStats = new ConcurrentDictionary<long, MultiplayerPlaylistItemStats>();
}
}
4 changes: 1 addition & 3 deletions osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using osu.Game.Online.API;
Expand All @@ -29,13 +28,12 @@ public class MultiplayerHub : StatefulUserHub<IMultiplayerClient, MultiplayerCli

public MultiplayerHub(
ILoggerFactory loggerFactory,
IDistributedCache cache,
EntityStore<ServerMultiplayerRoom> rooms,
EntityStore<MultiplayerClientState> users,
IDatabaseFactory databaseFactory,
ChatFilters chatFilters,
IHubContext<MultiplayerHub> hubContext)
: base(loggerFactory, cache, users)
: base(loggerFactory, users)
{
Rooms = rooms;
this.databaseFactory = databaseFactory;
Expand Down
4 changes: 1 addition & 3 deletions osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using osu.Game.Beatmaps;
using osu.Game.Online.API.Requests.Responses;
Expand Down Expand Up @@ -38,12 +37,11 @@ public class SpectatorHub : StatefulUserHub<ISpectatorClient, SpectatorClientSta

public SpectatorHub(
ILoggerFactory loggerFactory,
IDistributedCache cache,
EntityStore<SpectatorClientState> users,
IDatabaseFactory databaseFactory,
ScoreUploader scoreUploader,
IScoreProcessedSubscriber scoreProcessedSubscriber)
: base(loggerFactory, cache, users)
: base(loggerFactory, users)
{
this.databaseFactory = databaseFactory;
this.scoreUploader = scoreUploader;
Expand Down
2 changes: 0 additions & 2 deletions osu.Server.Spectator/Hubs/StatefulUserHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
using System.Threading.Tasks;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using osu.Game.Online;
using osu.Server.Spectator.Entities;
Expand All @@ -24,7 +23,6 @@ public abstract class StatefulUserHub<TClient, TUserState> : LoggingHub<TClient>

protected StatefulUserHub(
ILoggerFactory loggerFactory,
IDistributedCache cache,
EntityStore<TUserState> userStates)
: base(loggerFactory)
{
Expand Down
3 changes: 2 additions & 1 deletion osu.Server.Spectator/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ public void ConfigureServices(IServiceCollection services)
});

services.AddHubEntities()
.AddDatabaseServices();
.AddDatabaseServices()
.AddMemoryCache();

services.AddDistributedMemoryCache(); // replace with redis

Expand Down
Loading