Skip to content

Commit

Permalink
Merge pull request #242 from peppy/cached-stats
Browse files Browse the repository at this point in the history
Add caching and total room score tracking for multiplayer playlist item statistics
  • Loading branch information
bdach authored Jul 26, 2024
2 parents d53d1f8 + 40db2ed commit 30d269f
Show file tree
Hide file tree
Showing 16 changed files with 105 additions and 66 deletions.
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)
{
// 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

0 comments on commit 30d269f

Please sign in to comment.