From c484ee8a927b9bb009aee589f893a31471bb6fd3 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Tue, 10 Mar 2026 23:17:33 +0100 Subject: [PATCH 01/15] Implement CacheService (wip) --- .../Extensions/GameAssetExtensions.cs | 18 ++- Refresh.Core/Services/CacheService.cs | 137 ++++++++++++++++++ Refresh.Core/Types/Cache/CachedAssetData.cs | 9 ++ Refresh.Core/Types/Cache/CachedLevelTags.cs | 9 ++ .../Types/Cache/CachedSkillRewards.cs | 9 ++ Refresh.Core/Types/Data/DataContext.cs | 3 +- Refresh.Core/Types/Data/DataContextService.cs | 5 +- .../GameDatabaseContext.Levels.cs | 6 +- Refresh.GameServer/RefreshGameServer.cs | 1 + .../Endpoints/Admin/AdminLevelApiEndpoints.cs | 3 +- .../Response/Levels/ApiGameLevelResponse.cs | 4 +- .../Endpoints/LevelApiEndpoints.cs | 3 +- .../Endpoints/ResourceApiEndpoints.cs | 11 +- .../Extensions/GameLevelExtensions.cs | 2 +- .../DataTypes/Response/GameLevelResponse.cs | 2 +- .../Endpoints/Levels/PublishEndpoints.cs | 9 +- .../Endpoints/RelationEndpoints.cs | 3 +- .../Endpoints/ResourceEndpoints.cs | 4 +- .../Types/Levels/GameMinimalLevelResponse.cs | 4 +- .../Types/Playlists/SerializedLbp1Playlist.cs | 2 +- .../Types/UserData/SerializedUserHandle.cs | 2 +- RefreshTests.GameServer/TestContext.cs | 1 + .../Tests/Matching/MatchingTests.cs | 20 +++ 23 files changed, 241 insertions(+), 26 deletions(-) create mode 100644 Refresh.Core/Services/CacheService.cs create mode 100644 Refresh.Core/Types/Cache/CachedAssetData.cs create mode 100644 Refresh.Core/Types/Cache/CachedLevelTags.cs create mode 100644 Refresh.Core/Types/Cache/CachedSkillRewards.cs diff --git a/Refresh.Core/Extensions/GameAssetExtensions.cs b/Refresh.Core/Extensions/GameAssetExtensions.cs index 12cefc149..6bf064efe 100644 --- a/Refresh.Core/Extensions/GameAssetExtensions.cs +++ b/Refresh.Core/Extensions/GameAssetExtensions.cs @@ -151,7 +151,11 @@ private static string TransformImage(this GameAsset asset, TokenGame game, IData dataContext.DataStore, _ => null, () => asset.AsMainlinePhotoHash, - hash => dataContext.Database.SetMainlinePhotoHash(asset, hash), + hash => + { + dataContext.Database.SetMainlinePhotoHash(asset, hash); + dataContext.Cache.CacheAsset(asset.AssetHash, asset); + }, () => throw new NotSupportedException(), _ => throw new NotSupportedException() ); @@ -172,9 +176,17 @@ private static string TransformImage(this GameAsset asset, TokenGame game, IData dataContext.DataStore, CropToIcon, () => asset.AsMainlineIconHash, - hash => dataContext.Database.SetMainlineIconHash(asset, hash), + hash => + { + dataContext.Database.SetMainlineIconHash(asset, hash); + dataContext.Cache.CacheAsset(asset.AssetHash, asset); + }, () => asset.AsMipIconHash, - hash => dataContext.Database.SetMipIconHash(asset, hash) + hash => + { + dataContext.Database.SetMipIconHash(asset, hash); + dataContext.Cache.CacheAsset(asset.AssetHash, asset); + } ); } diff --git a/Refresh.Core/Services/CacheService.cs b/Refresh.Core/Services/CacheService.cs new file mode 100644 index 000000000..e3cfa21a2 --- /dev/null +++ b/Refresh.Core/Services/CacheService.cs @@ -0,0 +1,137 @@ +using Bunkum.Core; +using Bunkum.Core.Services; +using NotEnoughLogs; +using Refresh.Core.Types.Cache; +using Refresh.Database; +using Refresh.Database.Models.Assets; +using Refresh.Database.Models.Levels; + +namespace Refresh.Core.Services; + +public class CacheService : EndpointService +{ + private readonly TimeProviderService _time; + private readonly Dictionary _cachedAssetData = []; // hash -> data + private readonly Dictionary _cachedSkillRewards = []; // level ID -> data + private readonly Dictionary _cachedLevelTags = []; // level ID -> data + private const int LevelCacheDurationSeconds = 60 * 10; // only caching skill rewards and tags for now + private const int AssetCacheDurationSeconds = 60 * 60; // GameAssets basically never change for now + + public CacheService(Logger logger, TimeProviderService time) : base(logger) + { + this._time = time; + } + + #region Assets + public void CacheAsset(string hash, GameAsset? asset) + { + DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(AssetCacheDurationSeconds); + this._cachedAssetData[hash] = new() + { + Asset = asset, + ExpiresAt = expiresAt + }; + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheAsset {hash} - will expire in {expiresAt} Ä"); + } + + public GameAsset? GetAssetInfo(string hash, GameDatabaseContext database) + { + CachedAssetData? cached = this._cachedAssetData.GetValueOrDefault(hash); + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetAssetInfo {hash} - cached {cached} expires in {cached?.ExpiresAt} Ä"); + + if (cached == null || cached.ExpiresAt < this._time.TimeProvider.Now) + { + GameAsset? refreshed = database.GetAssetFromHash(hash); + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetAssetInfo {hash} - gotten from DB {refreshed} Ä"); + if (refreshed == null) return null; + + this.CacheAsset(hash, refreshed); + return refreshed; + } + + return cached.Asset; + } + + #endregion + + public void RemoveLevelData(GameLevel level) + { + this.RemoveSkillRewards(level); + this.RemoveTags(level); + } + + #region Skill Rewards + + public void CacheSkillRewards(GameLevel level, List rewards) + { + DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); + this._cachedSkillRewards[level.LevelId] = new() + { + SkillRewards = rewards, + ExpiresAt = expiresAt + }; + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheSkillRewards {level.LevelId} - will expire in {expiresAt} Ä"); + } + + public void RemoveSkillRewards(GameLevel level) + { + this._cachedSkillRewards.Remove(level.LevelId); + } + + public List GetSkillRewards(GameLevel level, GameDatabaseContext database) + { + CachedSkillRewards? cached = this._cachedSkillRewards.GetValueOrDefault(level.LevelId); + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetSkillRewards {level.LevelId} - cached {cached} expires in {cached?.ExpiresAt} Ä"); + + if (cached == null || cached.ExpiresAt < this._time.TimeProvider.Now) + { + List refreshed = database.GetSkillRewardsForLevel(level).ToList(); + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetSkillRewards {level.LevelId} - gotten from DB {refreshed} Ä"); + + this.CacheSkillRewards(level, refreshed); + return refreshed; + } + + return cached.SkillRewards; + } + + #endregion + #region Tags + + public void CacheTags(GameLevel level, List tags) + { + DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); + this._cachedLevelTags[level.LevelId] = new() + { + Tags = tags, + ExpiresAt = expiresAt + }; + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheTags {level.LevelId} - will expire in {expiresAt} Ä"); + } + + // Make service re-get tags the next time they're requested, incase a tag is added or removed. + // Can't just lazily add or remove tag to/from the cache because they're grouped and sorted by frequency (data we don't have here). + public void RemoveTags(GameLevel level) + { + this._cachedLevelTags.Remove(level.LevelId); + } + + public List GetTags(GameLevel level, GameDatabaseContext database) + { + CachedLevelTags? cached = this._cachedLevelTags.GetValueOrDefault(level.LevelId); + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetTags {level.LevelId} - cached {cached} expires in {cached?.ExpiresAt} Ä"); + + if (cached == null || cached.ExpiresAt < this._time.TimeProvider.Now) + { + List refreshed = database.GetTagsForLevel(level).ToList(); + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetTags {level.LevelId} - gotten from DB {refreshed} Ä"); + + this.CacheTags(level, refreshed); + return refreshed; + } + + return cached.Tags; + } + + #endregion +} \ No newline at end of file diff --git a/Refresh.Core/Types/Cache/CachedAssetData.cs b/Refresh.Core/Types/Cache/CachedAssetData.cs new file mode 100644 index 000000000..2f3cfdf80 --- /dev/null +++ b/Refresh.Core/Types/Cache/CachedAssetData.cs @@ -0,0 +1,9 @@ +using Refresh.Database.Models.Assets; + +namespace Refresh.Core.Types.Cache; + +public class CachedAssetData +{ + public GameAsset? Asset { get; set; } // incase asset doesn't exist + public DateTimeOffset ExpiresAt { get; set; } +} \ No newline at end of file diff --git a/Refresh.Core/Types/Cache/CachedLevelTags.cs b/Refresh.Core/Types/Cache/CachedLevelTags.cs new file mode 100644 index 000000000..9715b6b92 --- /dev/null +++ b/Refresh.Core/Types/Cache/CachedLevelTags.cs @@ -0,0 +1,9 @@ +using Refresh.Database.Models.Levels; + +namespace Refresh.Core.Types.Cache; + +public class CachedLevelTags +{ + public List Tags { get; set; } = []; + public DateTimeOffset ExpiresAt { get; set; } +} \ No newline at end of file diff --git a/Refresh.Core/Types/Cache/CachedSkillRewards.cs b/Refresh.Core/Types/Cache/CachedSkillRewards.cs new file mode 100644 index 000000000..a66a8b48b --- /dev/null +++ b/Refresh.Core/Types/Cache/CachedSkillRewards.cs @@ -0,0 +1,9 @@ +using Refresh.Database.Models.Levels; + +namespace Refresh.Core.Types.Cache; + +public class CachedSkillRewards +{ + public List SkillRewards { get; set; } = []; + public DateTimeOffset ExpiresAt { get; set; } +} \ No newline at end of file diff --git a/Refresh.Core/Types/Data/DataContext.cs b/Refresh.Core/Types/Data/DataContext.cs index c89bdc1fb..90889edc9 100644 --- a/Refresh.Core/Types/Data/DataContext.cs +++ b/Refresh.Core/Types/Data/DataContext.cs @@ -14,6 +14,7 @@ public class DataContext : IDataContext public required IDataStore DataStore { get; init; } public required MatchService Match { get; init; } public required GuidCheckerService GuidChecker { get; init; } + public required CacheService Cache { get; init; } public required Token? Token; public GameUser? User => this.Token?.User; @@ -22,6 +23,6 @@ public class DataContext : IDataContext public string GetIconFromHash(string hash) { - return this.Database.GetAssetFromHash(hash)?.GetAsIcon(this.Game, this) ?? hash; + return this.Cache.GetAssetInfo(hash, this.Database)?.GetAsIcon(this.Game, this) ?? hash; } } \ No newline at end of file diff --git a/Refresh.Core/Types/Data/DataContextService.cs b/Refresh.Core/Types/Data/DataContextService.cs index a35cdf4bf..0757c7a5c 100644 --- a/Refresh.Core/Types/Data/DataContextService.cs +++ b/Refresh.Core/Types/Data/DataContextService.cs @@ -16,13 +16,15 @@ public class DataContextService : Service private readonly MatchService _matchService; private readonly AuthenticationService _authService; private readonly GuidCheckerService _guidCheckerService; + private readonly CacheService _cacheService; - public DataContextService(StorageService storage, MatchService match, AuthenticationService auth, Logger logger, GuidCheckerService guidChecker) : base(logger) + public DataContextService(StorageService storage, MatchService match, AuthenticationService auth, Logger logger, GuidCheckerService guidChecker, CacheService cache) : base(logger) { this._storageService = storage; this._matchService = match; this._authService = auth; this._guidCheckerService = guidChecker; + this._cacheService = cache; } private static T StealInjection(Service service, ListenerContext? context = null, Lazy? database = null, string name = "") @@ -42,6 +44,7 @@ private static T StealInjection(Service service, ListenerContext? context = n Match = this._matchService, Token = StealInjection(this._authService, context, database), GuidChecker = this._guidCheckerService, + Cache = this._cacheService, }; } diff --git a/Refresh.Database/GameDatabaseContext.Levels.cs b/Refresh.Database/GameDatabaseContext.Levels.cs index caccd7315..e56703739 100644 --- a/Refresh.Database/GameDatabaseContext.Levels.cs +++ b/Refresh.Database/GameDatabaseContext.Levels.cs @@ -624,9 +624,10 @@ public IEnumerable GetSkillRewardsForLevel(GameLevel level) return this.GameSkillRewards.Where(r => r.LevelId == level.LevelId); } - public void UpdateSkillRewardsForLevel(GameLevel level, IEnumerable rewards) + public List UpdateSkillRewardsForLevel(GameLevel level, IEnumerable rewards) { this.GameSkillRewards.RemoveRange(this.GetSkillRewardsForLevel(level)); + List ret = []; this.Write(() => { @@ -643,8 +644,11 @@ public void UpdateSkillRewardsForLevel(GameLevel level, IEnumerable(); this.Server.AddService(); this.Server.AddService(); + this.Server.AddService(); if(this._configStore.Integration!.AipiEnabled) this.Server.AddService(); diff --git a/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminLevelApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminLevelApiEndpoints.cs index 77e6ec27a..29dd9bb12 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminLevelApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminLevelApiEndpoints.cs @@ -73,12 +73,13 @@ public ApiResponse EditLevelById(RequestContext context, G [ApiV3Endpoint("admin/levels/id/{id}", HttpMethods.Delete), MinimumRole(GameUserRole.Moderator)] [DocSummary("Deletes a level.")] [DocError(typeof(ApiNotFoundError), ApiNotFoundError.LevelMissingErrorWhen)] - public ApiOkResponse DeleteLevel(RequestContext context, GameDatabaseContext database, int id) + public ApiOkResponse DeleteLevel(RequestContext context, GameDatabaseContext database, int id, DataContext dataContext) { GameLevel? level = database.GetLevelById(id); if (level == null) return ApiNotFoundError.LevelMissingError; database.DeleteLevel(level); + dataContext.Cache.RemoveLevelData(level); return new ApiOkResponse(); } diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiGameLevelResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiGameLevelResponse.cs index 8bf90e61e..b2361b26f 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiGameLevelResponse.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiGameLevelResponse.cs @@ -83,7 +83,7 @@ public class ApiGameLevelResponse : IApiResponse, IDataConvertableFrom EditLevelById(RequestContext context, [DocSummary("Deletes a level by the level's numerical ID")] [DocError(typeof(ApiNotFoundError), ApiNotFoundError.LevelMissingErrorWhen)] [DocError(typeof(ApiAuthenticationError), ApiAuthenticationError.NoPermissionsForObjectWhen)] - public ApiOkResponse DeleteLevelById(RequestContext context, GameDatabaseContext database, GameUser user, + public ApiOkResponse DeleteLevelById(RequestContext context, GameDatabaseContext database, GameUser user, DataContext dataContext, [DocSummary("The ID of the level")] int id) { GameLevel? level = database.GetLevelById(id); @@ -101,6 +101,7 @@ public ApiOkResponse DeleteLevelById(RequestContext context, GameDatabaseContext return ApiAuthenticationError.NoPermissionsForObject; database.DeleteLevel(level); + dataContext.Cache.RemoveLevelData(level); return new ApiOkResponse(); } diff --git a/Refresh.Interfaces.APIv3/Endpoints/ResourceApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/ResourceApiEndpoints.cs index 8f3382b18..0fe86e0d5 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/ResourceApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/ResourceApiEndpoints.cs @@ -76,7 +76,7 @@ public Response DownloadPspGameAsset(RequestContext context, IDataStore dataStor [DocError(typeof(ApiValidationError), ApiValidationError.HashMissingErrorWhen)] [RateLimitSettings(RequestTimeoutDuration, ImageAssetRequestAmount, RequestBlockDuration, ImageAssetBucket)] public Response DownloadGameAssetAsImage(RequestContext context, IDataStore dataStore, GameDatabaseContext database, - [DocSummary("The SHA1 hash of the asset")] string hash, ImageImporter imageImport, AssetImporter assetImport) + [DocSummary("The SHA1 hash of the asset")] string hash, ImageImporter imageImport, AssetImporter assetImport, DataContext dataContext) { bool isPspAsset = hash.StartsWith("psp/"); @@ -99,7 +99,7 @@ public Response DownloadGameAssetAsImage(RequestContext context, IDataStore data else { //Import the asset as normal - GameAsset? asset = database.GetAssetFromHash(realHash); + GameAsset? asset = dataContext.Cache.GetAssetInfo(realHash, database); imageImport.ImportAsset(realHash, isPspAsset, asset?.AssetType, dataStore); } } @@ -118,8 +118,8 @@ public Response DownloadGameAssetAsImage(RequestContext context, IDataStore data [DocError(typeof(ApiValidationError), ApiValidationError.HashMissingErrorWhen)] [RateLimitSettings(RequestTimeoutDuration, ImageAssetRequestAmount, RequestBlockDuration, ImageAssetBucket)] public Response DownloadPspGameAssetAsImage(RequestContext context, IDataStore dataStore, GameDatabaseContext database, - [DocSummary("The SHA1 hash of the asset")] string hash, ImageImporter imageImport, AssetImporter assetImport) - => this.DownloadGameAssetAsImage(context, dataStore, database, $"psp/{hash}", imageImport, assetImport); + [DocSummary("The SHA1 hash of the asset")] string hash, ImageImporter imageImport, AssetImporter assetImport, DataContext dataContext) + => this.DownloadGameAssetAsImage(context, dataStore, database, $"psp/{hash}", imageImport, assetImport, dataContext); [ApiV3Endpoint("assets/{hash}"), Authentication(false)] [DocSummary("Gets information from the database about a particular hash. Includes user who uploaded, dependencies, timestamps, etc.")] @@ -138,7 +138,7 @@ public ApiResponse GetAssetInfo(RequestContext context, Ga if (!CommonPatterns.Sha1Regex().IsMatch(realHash)) return ApiValidationError.HashInvalidError; if (string.IsNullOrWhiteSpace(realHash)) return ApiValidationError.HashMissingError; - GameAsset? asset = database.GetAssetFromHash(realHash); + GameAsset? asset = dataContext.Cache.GetAssetInfo(realHash, database); if (asset == null) return ApiNotFoundError.Instance; return ApiGameAssetResponse.FromOld(asset, dataContext); @@ -212,6 +212,7 @@ IntegrationConfig integration } database.AddAssetToDatabase(gameAsset); + dataContext.Cache.CacheAsset(gameAsset.AssetHash, gameAsset); return new ApiResponse(ApiGameAssetResponse.FromOld(gameAsset, dataContext)!, Created); } diff --git a/Refresh.Interfaces.APIv3/Extensions/GameLevelExtensions.cs b/Refresh.Interfaces.APIv3/Extensions/GameLevelExtensions.cs index 450c7f922..3fecdbaa3 100644 --- a/Refresh.Interfaces.APIv3/Extensions/GameLevelExtensions.cs +++ b/Refresh.Interfaces.APIv3/Extensions/GameLevelExtensions.cs @@ -8,7 +8,7 @@ public static class GameLevelExtensions { public static string GetIconHash(this GameLevel level, DataContext dataContext) { - string hash = dataContext.Database.GetAssetFromHash(level.IconHash)?.GetAsIcon(TokenGame.Website, dataContext) ?? level.IconHash; + string hash = dataContext.GetIconFromHash(level.IconHash); return level.GameVersion == TokenGame.LittleBigPlanetPSP ? "psp/" + hash : hash; diff --git a/Refresh.Interfaces.Game/Endpoints/DataTypes/Response/GameLevelResponse.cs b/Refresh.Interfaces.Game/Endpoints/DataTypes/Response/GameLevelResponse.cs index 721df6cd5..b6e498c5d 100644 --- a/Refresh.Interfaces.Game/Endpoints/DataTypes/Response/GameLevelResponse.cs +++ b/Refresh.Interfaces.Game/Endpoints/DataTypes/Response/GameLevelResponse.cs @@ -154,7 +154,7 @@ private static GameLevelResponse FromMinimal(GameMinimalLevelResponse minimal) }); } - response.SkillRewards = dataContext.Database.GetSkillRewardsForLevel(old).ToList(); + response.SkillRewards = dataContext.Cache.GetSkillRewards(old, dataContext.Database); } return response; diff --git a/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs index c5144c5c3..01e638989 100644 --- a/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs @@ -240,12 +240,14 @@ public Response PublishLevel(RequestContext context, dataContext.Database.UpdateLevelModdedStatus(levelToUpdate); } - dataContext.Database.UpdateSkillRewardsForLevel(levelToUpdate, body.SkillRewards); + List updatedRewards = dataContext.Database.UpdateSkillRewardsForLevel(levelToUpdate, body.SkillRewards); + dataContext.Cache.CacheSkillRewards(levelToUpdate, updatedRewards); return new Response(GameLevelResponse.FromOld(levelToUpdate, dataContext)!, ContentType.Xml); } GameLevel newLevel = dataContext.Database.AddLevel(body, dataContext.Game, user); - dataContext.Database.UpdateSkillRewardsForLevel(newLevel, body.SkillRewards); + List newRewards = dataContext.Database.UpdateSkillRewardsForLevel(newLevel, body.SkillRewards); + dataContext.Cache.CacheSkillRewards(newLevel, newRewards); context.Logger.LogInfo(BunkumCategory.UserContent, "User {0} (id: {1}) uploaded level id {2}", user.Username, user.UserId, newLevel.LevelId); @@ -266,7 +268,7 @@ public Response PublishLevel(RequestContext context, } [GameEndpoint("unpublish/{id}", ContentType.Xml, HttpMethods.Post)] - public Response DeleteLevel(RequestContext context, GameUser user, GameDatabaseContext database, int id) + public Response DeleteLevel(RequestContext context, GameUser user, GameDatabaseContext database, int id, DataContext dataContext) { GameLevel? level = database.GetLevelById(id); if (level == null) return NotFound; @@ -274,6 +276,7 @@ public Response DeleteLevel(RequestContext context, GameUser user, GameDatabaseC if (level.Publisher?.UserId != user.UserId) return Unauthorized; database.DeleteLevel(level); + dataContext.Cache.RemoveLevelData(level); return OK; } } \ No newline at end of file diff --git a/Refresh.Interfaces.Game/Endpoints/RelationEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/RelationEndpoints.cs index a38364584..c5f6459d1 100644 --- a/Refresh.Interfaces.Game/Endpoints/RelationEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/RelationEndpoints.cs @@ -152,7 +152,7 @@ public Response ClearQueue(RequestContext context, GameDatabaseContext database, [RequireEmailVerified] [RateLimitSettings(LevelTaggingEndpointLimits.TimeoutDuration, LevelTaggingEndpointLimits.RequestAmount, LevelTaggingEndpointLimits.BlockDuration, LevelTaggingEndpointLimits.RequestBucket)] - public Response SubmitTagsForLevel(RequestContext context, GameDatabaseContext database, GameUser user, + public Response SubmitTagsForLevel(RequestContext context, GameDatabaseContext database, GameUser user, DataContext dataContext, string slotType, int id, string body, GameServerConfig config) { if (user.IsWriteBlocked(config)) @@ -174,6 +174,7 @@ public Response SubmitTagsForLevel(RequestContext context, GameDatabaseContext d return BadRequest; database.AddTagRelation(user, level, tag.Value); + dataContext.Cache.RemoveTags(level); // Reset return OK; } diff --git a/Refresh.Interfaces.Game/Endpoints/ResourceEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/ResourceEndpoints.cs index a5527283c..f54671c2e 100644 --- a/Refresh.Interfaces.Game/Endpoints/ResourceEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/ResourceEndpoints.cs @@ -32,7 +32,8 @@ public class ResourceEndpoints : EndpointGroup [SuppressMessage("ReSharper", "ConvertIfStatementToReturnStatement")] [RateLimitSettings(450, 180, 300, "game-asset-upload")] public Response UploadAsset(RequestContext context, string hash, string type, byte[] body, IDataStore dataStore, - GameDatabaseContext database, GameUser user, AssetImporter importer, GameServerConfig config, IDateTimeProvider timeProvider, Token token) + GameDatabaseContext database, GameUser user, AssetImporter importer, GameServerConfig config, IDateTimeProvider timeProvider, Token token, + DataContext dataContext) { if (user.IsWriteBlocked(config)) return Unauthorized; @@ -90,6 +91,7 @@ public Response UploadAsset(RequestContext context, string hash, string type, by gameAsset.OriginalUploader = user; database.AddAssetToDatabase(gameAsset); + dataContext.Cache.CacheAsset(gameAsset.AssetHash, gameAsset); database.IncrementUserFilesizeQuota(user, body.Length); diff --git a/Refresh.Interfaces.Game/Types/Levels/GameMinimalLevelResponse.cs b/Refresh.Interfaces.Game/Types/Levels/GameMinimalLevelResponse.cs index 2dd0b1915..9e7b46b0e 100644 --- a/Refresh.Interfaces.Game/Types/Levels/GameMinimalLevelResponse.cs +++ b/Refresh.Interfaces.Game/Types/Levels/GameMinimalLevelResponse.cs @@ -200,7 +200,7 @@ public static GameMinimalLevelResponse FromHash(string hash, DataContext dataCon if (dataContext.Game is TokenGame.LittleBigPlanet1 or TokenGame.BetaBuild) { - response.Tags = string.Join(',', dataContext.Database.GetTagsForLevel(old).Select(t => t.ToLbpString())); + response.Tags = string.Join(',', dataContext.Cache.GetTags(old, dataContext.Database).Select(t => t.ToLbpString())); } if (dataContext.Game is not TokenGame.LittleBigPlanet1 or TokenGame.LittleBigPlanetPSP) @@ -210,7 +210,7 @@ public static GameMinimalLevelResponse FromHash(string hash, DataContext dataCon response.PublisherLabels = old.Labels.ToLbpCommaList(dataContext.Game); } - response.IconHash = dataContext.Database.GetAssetFromHash(old.IconHash)?.GetAsIcon(dataContext.Game, dataContext) ?? old.IconHash; + response.IconHash = dataContext.GetIconFromHash(old.IconHash); return response; } diff --git a/Refresh.Interfaces.Game/Types/Playlists/SerializedLbp1Playlist.cs b/Refresh.Interfaces.Game/Types/Playlists/SerializedLbp1Playlist.cs index ee406de88..9343f8888 100644 --- a/Refresh.Interfaces.Game/Types/Playlists/SerializedLbp1Playlist.cs +++ b/Refresh.Interfaces.Game/Types/Playlists/SerializedLbp1Playlist.cs @@ -36,7 +36,7 @@ public class SerializedLbp1Playlist : IDataConvertableFrom(), Token = token, GuidChecker = this.GetService(), + Cache = this.GetService(), }; } diff --git a/RefreshTests.GameServer/Tests/Matching/MatchingTests.cs b/RefreshTests.GameServer/Tests/Matching/MatchingTests.cs index daa8b89df..3489c8562 100644 --- a/RefreshTests.GameServer/Tests/Matching/MatchingTests.cs +++ b/RefreshTests.GameServer/Tests/Matching/MatchingTests.cs @@ -44,6 +44,7 @@ public void CreatesRooms() Match = match, GuidChecker = null!, Token = token1, + Cache = null!, }, config); match.ExecuteMethod("CreateRoom", roomData, new DataContext { @@ -53,6 +54,7 @@ public void CreatesRooms() Match = match, Token = token2, GuidChecker = null!, + Cache = null!, }, config); Assert.Multiple(() => @@ -101,6 +103,7 @@ public void DoesntMatchIfNoRooms() DataStore = null!, //this isn't accessed by matching Match = match, Token = token1, + Cache = null!, }, config); // Tell user1 to try to find a room @@ -118,6 +121,7 @@ public void DoesntMatchIfNoRooms() DataStore = null!, //this isn't accessed by matching Match = match, Token = token1, + Cache = null!, }, config); // Deserialize the result @@ -162,6 +166,7 @@ public void StrictNatCantJoinStrict() Match = match, GuidChecker = null!, Token = token1, + Cache = null!, }, config); match.ExecuteMethod("CreateRoom", roomData, new DataContext { @@ -171,6 +176,7 @@ public void StrictNatCantJoinStrict() Match = match, Token = token2, GuidChecker = null!, + Cache = null!, }, config); // Tell user2 to try to find a room @@ -181,6 +187,7 @@ public void StrictNatCantJoinStrict() Match = match, GuidChecker = null!, Token = token2, + Cache = null!, }, config); //Deserialize the result @@ -234,6 +241,7 @@ public void StrictNatCanJoinOpen() Match = match, Token = token1, GuidChecker = null!, + Cache = null!, }, config); match.ExecuteMethod("CreateRoom", roomData2, new DataContext { @@ -243,6 +251,7 @@ public void StrictNatCanJoinOpen() Match = match, Token = token2, GuidChecker = null!, + Cache = null!, }, config); // Tell user2 to try to find a room @@ -259,6 +268,7 @@ public void StrictNatCanJoinOpen() Match = match, Token = token2, GuidChecker = null!, + Cache = null!, }, config); Assert.That(response.StatusCode, Is.EqualTo(OK)); } @@ -295,6 +305,7 @@ public void MatchesPlayersTogether() Match = match, Token = token1, GuidChecker = null!, + Cache = null!, }, config); match.ExecuteMethod("CreateRoom", roomData, new DataContext { @@ -304,6 +315,7 @@ public void MatchesPlayersTogether() Match = match, Token = token2, GuidChecker = null!, + Cache = null!, }, config); // Tell user2 to try to find a room @@ -320,6 +332,7 @@ public void MatchesPlayersTogether() Match = match, Token = token2, GuidChecker = null!, + Cache = null!, }, config); Assert.That(response.StatusCode, Is.EqualTo(OK)); } @@ -358,6 +371,7 @@ public void HostCanSetPlayersInRoom() Match = match, Token = token1, GuidChecker = null!, + Cache = null!, }, config); match.ExecuteMethod("CreateRoom", roomData, new DataContext { @@ -367,6 +381,7 @@ public void HostCanSetPlayersInRoom() Match = match, Token = token2, GuidChecker = null!, + Cache = null!, }, config); // Get user1 and user2 in the same room @@ -384,6 +399,7 @@ public void HostCanSetPlayersInRoom() Match = match, Token = token1, GuidChecker = null!, + Cache = null!, }, config); GameRoom? room = match.RoomAccessor.GetRoomByUser(user1); Assert.Multiple(() => @@ -427,6 +443,7 @@ public void PlayersCanLeaveAndSplitIntoNewRoom() Match = match, Token = token1, GuidChecker = null!, + Cache = null!, }, config); match.ExecuteMethod("CreateRoom", roomData, new DataContext { @@ -436,6 +453,7 @@ public void PlayersCanLeaveAndSplitIntoNewRoom() Match = match, Token = token2, GuidChecker = null!, + Cache = null!, }, config); // Get user1 and user2 in the same room @@ -454,6 +472,7 @@ public void PlayersCanLeaveAndSplitIntoNewRoom() Match = match, Token = token1, GuidChecker = null!, + Cache = null!, }, config); GameRoom? user1Room = match.RoomAccessor.GetRoomByUser(user1); Assert.That(user1Room, Is.Not.Null); @@ -469,6 +488,7 @@ public void PlayersCanLeaveAndSplitIntoNewRoom() Match = match, Token = token2, GuidChecker = null!, + Cache = null!, }, config); GameRoom? user1Room = match.RoomAccessor.GetRoomByUser(user1); GameRoom? user2Room = match.RoomAccessor.GetRoomByUser(user2); From 747291b06408821c237c844adee905bb52e46f4f Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Tue, 10 Mar 2026 23:52:45 +0100 Subject: [PATCH 02/15] Cache own relations aswell (also wip) --- Refresh.Core/Services/CacheService.cs | 82 +++++++++++++++++++ Refresh.Core/Types/Cache/CachedData.cs | 10 +++ .../Types/Cache/CachedOwnLevelRelations.cs | 9 ++ .../Types/Cache/CachedOwnUserRelations.cs | 9 ++ .../Types/Relations/OwnLevelRelations.cs | 11 +++ .../Types/Relations/OwnUserRelations.cs | 6 ++ .../ApiGameLevelOwnRelationsResponse.cs | 16 ++-- .../Users/ApiGameUserOwnRelationsResponse.cs | 5 +- 8 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 Refresh.Core/Types/Cache/CachedData.cs create mode 100644 Refresh.Core/Types/Cache/CachedOwnLevelRelations.cs create mode 100644 Refresh.Core/Types/Cache/CachedOwnUserRelations.cs create mode 100644 Refresh.Core/Types/Relations/OwnLevelRelations.cs create mode 100644 Refresh.Core/Types/Relations/OwnUserRelations.cs diff --git a/Refresh.Core/Services/CacheService.cs b/Refresh.Core/Services/CacheService.cs index e3cfa21a2..f8e1aac42 100644 --- a/Refresh.Core/Services/CacheService.cs +++ b/Refresh.Core/Services/CacheService.cs @@ -1,10 +1,13 @@ using Bunkum.Core; using Bunkum.Core.Services; +using MongoDB.Bson; using NotEnoughLogs; using Refresh.Core.Types.Cache; +using Refresh.Core.Types.Relations; using Refresh.Database; using Refresh.Database.Models.Assets; using Refresh.Database.Models.Levels; +using Refresh.Database.Models.Users; namespace Refresh.Core.Services; @@ -14,9 +17,15 @@ public class CacheService : EndpointService private readonly Dictionary _cachedAssetData = []; // hash -> data private readonly Dictionary _cachedSkillRewards = []; // level ID -> data private readonly Dictionary _cachedLevelTags = []; // level ID -> data + + // TODO: maybe save these in DB aswell? + private readonly Dictionary> _cachedOwnUserRelations = []; // source user UUID -> target user UUID -> data + private readonly Dictionary> _cachedOwnLevelRelations = []; // source user UUID -> level ID -> data + private const int LevelCacheDurationSeconds = 60 * 10; // only caching skill rewards and tags for now private const int AssetCacheDurationSeconds = 60 * 60; // GameAssets basically never change for now + // TODO: some way to auto-remove cached stuff if expired public CacheService(Logger logger, TimeProviderService time) : base(logger) { this._time = time; @@ -134,4 +143,77 @@ public List GetTags(GameLevel level, GameDatabaseContext database) } #endregion + + #region Own User Relations + + public void CacheOwnUserRelations(GameUser source, GameUser target, OwnUserRelations newData) + { + DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); + this._cachedOwnUserRelations[source.UserId][target.UserId] = new() + { + Relations = newData, + ExpiresAt = expiresAt + }; + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheOwnUserRelations s {source.UserId} t {target.UserId} - will expire in {expiresAt} Ä"); + } + public OwnUserRelations GetOwnUserRelations(GameUser source, GameUser target, GameDatabaseContext database) + { + CachedOwnUserRelations? cached = this._cachedOwnUserRelations.GetValueOrDefault(source.UserId)?.GetValueOrDefault(target.UserId); + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnUserRelations s {source.UserId} t {target.UserId} - cached {cached} expires in {cached?.ExpiresAt} Ä"); + + if (cached == null || cached.ExpiresAt < this._time.TimeProvider.Now) + { + OwnUserRelations refreshed = new() + { + IsHearted = database.IsUserFavouritedByUser(target, source), + }; + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnUserRelations s {source.UserId} t {target.UserId} - gotten from DB {refreshed} Ä"); + + this.CacheOwnUserRelations(source, target, refreshed); + return refreshed; + } + + return cached.Relations; + } + + #endregion + #region Own Level Relations + + public void CacheOwnLevelRelations(GameUser source, GameLevel target, OwnLevelRelations newData) + { + DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); + this._cachedOwnLevelRelations[source.UserId][target.LevelId] = new() + { + Relations = newData, + ExpiresAt = expiresAt + }; + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheOwnLevelRelations s {source.UserId} t {target.LevelId} - will expire in {expiresAt} Ä"); + } + + public OwnLevelRelations GetOwnLevelRelations(GameUser source, GameLevel target, GameDatabaseContext database) + { + CachedOwnLevelRelations? cached = this._cachedOwnLevelRelations.GetValueOrDefault(source.UserId)?.GetValueOrDefault(target.LevelId); + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnLevelRelations s {source.UserId} t {target.LevelId} - cached {cached} expires in {cached?.ExpiresAt} Ä"); + + if (cached == null || cached.ExpiresAt < this._time.TimeProvider.Now) + { + OwnLevelRelations refreshed = new() + { + IsHearted = database.IsLevelFavouritedByUser(target, source), + IsQueued = database.IsLevelQueuedByUser(target, source), + LevelRating = (int?)database.GetRatingByUser(target, source) ?? 0, + TotalPlayCount = database.GetTotalPlaysForLevelByUser(target, source), + TotalCompletionCount = database.GetTotalCompletionsForLevelByUser(target, source), + PhotoCount = database.GetTotalPhotosInLevelByUser(target, source) + }; + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnLevelRelations s {source.UserId} t {target.LevelId} - gotten from DB {refreshed} Ä"); + + this.CacheOwnLevelRelations(source, target, refreshed); + return refreshed; + } + + return cached.Relations; + } + + #endregion } \ No newline at end of file diff --git a/Refresh.Core/Types/Cache/CachedData.cs b/Refresh.Core/Types/Cache/CachedData.cs new file mode 100644 index 000000000..09269a175 --- /dev/null +++ b/Refresh.Core/Types/Cache/CachedData.cs @@ -0,0 +1,10 @@ +namespace Refresh.Core.Types.Cache; + +#nullable disable + +// TODO: use this to cache various data +public class CachedData +{ + public TCachedData Cached { get; set; } + public DateTimeOffset ExpiresAt { get; set; } +} \ No newline at end of file diff --git a/Refresh.Core/Types/Cache/CachedOwnLevelRelations.cs b/Refresh.Core/Types/Cache/CachedOwnLevelRelations.cs new file mode 100644 index 000000000..0dfe5fd29 --- /dev/null +++ b/Refresh.Core/Types/Cache/CachedOwnLevelRelations.cs @@ -0,0 +1,9 @@ +using Refresh.Core.Types.Relations; + +namespace Refresh.Core.Types.Cache; + +public class CachedOwnLevelRelations +{ + public OwnLevelRelations Relations { get; set; } = null!; + public DateTimeOffset ExpiresAt { get; set; } +} \ No newline at end of file diff --git a/Refresh.Core/Types/Cache/CachedOwnUserRelations.cs b/Refresh.Core/Types/Cache/CachedOwnUserRelations.cs new file mode 100644 index 000000000..3e18b7df7 --- /dev/null +++ b/Refresh.Core/Types/Cache/CachedOwnUserRelations.cs @@ -0,0 +1,9 @@ +using Refresh.Core.Types.Relations; + +namespace Refresh.Core.Types.Cache; + +public class CachedOwnUserRelations +{ + public OwnUserRelations Relations { get; set; } = null!; + public DateTimeOffset ExpiresAt { get; set; } +} \ No newline at end of file diff --git a/Refresh.Core/Types/Relations/OwnLevelRelations.cs b/Refresh.Core/Types/Relations/OwnLevelRelations.cs new file mode 100644 index 000000000..d0ae3f04a --- /dev/null +++ b/Refresh.Core/Types/Relations/OwnLevelRelations.cs @@ -0,0 +1,11 @@ +namespace Refresh.Core.Types.Relations; + +public class OwnLevelRelations +{ + public bool IsHearted { get; set; } + public bool IsQueued { get; set; } + public int LevelRating { get; set; } + public int TotalPlayCount { get; set; } + public int TotalCompletionCount { get; set; } + public int PhotoCount { get; set; } +} \ No newline at end of file diff --git a/Refresh.Core/Types/Relations/OwnUserRelations.cs b/Refresh.Core/Types/Relations/OwnUserRelations.cs new file mode 100644 index 000000000..03f121cc4 --- /dev/null +++ b/Refresh.Core/Types/Relations/OwnUserRelations.cs @@ -0,0 +1,6 @@ +namespace Refresh.Core.Types.Relations; + +public class OwnUserRelations +{ + public bool IsHearted { get; set; } +} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiGameLevelOwnRelationsResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiGameLevelOwnRelationsResponse.cs index ec74e0de3..a7a8014d6 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiGameLevelOwnRelationsResponse.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiGameLevelOwnRelationsResponse.cs @@ -1,4 +1,5 @@ using Refresh.Core.Types.Data; +using Refresh.Core.Types.Relations; using Refresh.Database.Models.Levels; namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels; @@ -21,16 +22,17 @@ public class ApiGameLevelOwnRelationsResponse : IApiResponse { if (dataContext.User == null) return null; + + OwnLevelRelations relations = dataContext.Cache.GetOwnLevelRelations(dataContext.User, level, dataContext.Database); return new() { - // TODO: Probably cache these stats aswell - IsHearted = dataContext.Database.IsLevelFavouritedByUser(level, dataContext.User), - IsQueued = dataContext.Database.IsLevelQueuedByUser(level, dataContext.User), - LevelRating = (int?)dataContext.Database.GetRatingByUser(level, dataContext.User) ?? 0, - MyPlaysCount = dataContext.Database.GetTotalPlaysForLevelByUser(level, dataContext.User), - CompletionCount = dataContext.Database.GetTotalCompletionsForLevelByUser(level, dataContext.User), - PhotoCount = dataContext.Database.GetTotalPhotosInLevelByUser(level, dataContext.User) + IsHearted = relations.IsHearted, + IsQueued = relations.IsQueued, + LevelRating = relations.LevelRating, + MyPlaysCount = relations.TotalPlayCount, + CompletionCount = relations.TotalCompletionCount, + PhotoCount = relations.PhotoCount }; } } diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserOwnRelationsResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserOwnRelationsResponse.cs index 5014ab1f6..5950b26c9 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserOwnRelationsResponse.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserOwnRelationsResponse.cs @@ -1,4 +1,5 @@ using Refresh.Core.Types.Data; +using Refresh.Core.Types.Relations; using Refresh.Database.Models.Users; namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users; @@ -12,10 +13,12 @@ public class ApiGameUserOwnRelationsResponse : IApiResponse { if (dataContext.User == null) return null; + + OwnUserRelations relations = dataContext.Cache.GetOwnUserRelations(dataContext.User, user, dataContext.Database); return new() { - IsHearted = dataContext.Database.IsUserFavouritedByUser(user, dataContext.User), + IsHearted = relations.IsHearted, }; } } \ No newline at end of file From 5a4d77acc07a114cf25205794327e4db4900f953 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 11 Mar 2026 11:19:12 +0100 Subject: [PATCH 03/15] Use CachedData, outline own relation partial cache updates --- Refresh.Core/Services/CacheService.cs | 159 +++++++++++++----- Refresh.Core/Types/Cache/CachedAssetData.cs | 9 - Refresh.Core/Types/Cache/CachedData.cs | 6 +- Refresh.Core/Types/Cache/CachedLevelTags.cs | 9 - .../Types/Cache/CachedOwnLevelRelations.cs | 9 - .../Types/Cache/CachedOwnUserRelations.cs | 9 - .../Types/Cache/CachedSkillRewards.cs | 9 - .../Endpoints/LevelApiEndpoints.cs | 8 +- 8 files changed, 124 insertions(+), 94 deletions(-) delete mode 100644 Refresh.Core/Types/Cache/CachedAssetData.cs delete mode 100644 Refresh.Core/Types/Cache/CachedLevelTags.cs delete mode 100644 Refresh.Core/Types/Cache/CachedOwnLevelRelations.cs delete mode 100644 Refresh.Core/Types/Cache/CachedOwnUserRelations.cs delete mode 100644 Refresh.Core/Types/Cache/CachedSkillRewards.cs diff --git a/Refresh.Core/Services/CacheService.cs b/Refresh.Core/Services/CacheService.cs index f8e1aac42..37b58de0e 100644 --- a/Refresh.Core/Services/CacheService.cs +++ b/Refresh.Core/Services/CacheService.cs @@ -14,15 +14,15 @@ namespace Refresh.Core.Services; public class CacheService : EndpointService { private readonly TimeProviderService _time; - private readonly Dictionary _cachedAssetData = []; // hash -> data - private readonly Dictionary _cachedSkillRewards = []; // level ID -> data - private readonly Dictionary _cachedLevelTags = []; // level ID -> data + private readonly Dictionary> _cachedAssetData = []; // hash -> data + private readonly Dictionary>> _cachedSkillRewards = []; // level ID -> data + private readonly Dictionary>> _cachedLevelTags = []; // level ID -> data // TODO: maybe save these in DB aswell? - private readonly Dictionary> _cachedOwnUserRelations = []; // source user UUID -> target user UUID -> data - private readonly Dictionary> _cachedOwnLevelRelations = []; // source user UUID -> level ID -> data + private readonly Dictionary>> _cachedOwnUserRelations = []; // source user UUID -> target user UUID -> data + private readonly Dictionary>> _cachedOwnLevelRelations = []; // source user UUID -> level ID -> data - private const int LevelCacheDurationSeconds = 60 * 10; // only caching skill rewards and tags for now + private const int LevelCacheDurationSeconds = 60 * 10; private const int AssetCacheDurationSeconds = 60 * 60; // GameAssets basically never change for now // TODO: some way to auto-remove cached stuff if expired @@ -37,7 +37,7 @@ public void CacheAsset(string hash, GameAsset? asset) DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(AssetCacheDurationSeconds); this._cachedAssetData[hash] = new() { - Asset = asset, + Cached = asset, ExpiresAt = expiresAt }; this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheAsset {hash} - will expire in {expiresAt} Ä"); @@ -45,20 +45,21 @@ public void CacheAsset(string hash, GameAsset? asset) public GameAsset? GetAssetInfo(string hash, GameDatabaseContext database) { - CachedAssetData? cached = this._cachedAssetData.GetValueOrDefault(hash); + CachedData? cached = this._cachedAssetData.GetValueOrDefault(hash); this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetAssetInfo {hash} - cached {cached} expires in {cached?.ExpiresAt} Ä"); - if (cached == null || cached.ExpiresAt < this._time.TimeProvider.Now) + if (this.HasCacheExpired(cached)) { GameAsset? refreshed = database.GetAssetFromHash(hash); this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetAssetInfo {hash} - gotten from DB {refreshed} Ä"); - if (refreshed == null) return null; + // Cache anyway, even if asset was not found in DB, for the same reason we're caching everything else + // If the asset ever happens to be added to DB, it will be added to cache aswell by whatever is handling the upload/import anyway this.CacheAsset(hash, refreshed); return refreshed; } - return cached.Asset; + return cached!.Cached; } #endregion @@ -76,7 +77,7 @@ public void CacheSkillRewards(GameLevel level, List rewards) DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); this._cachedSkillRewards[level.LevelId] = new() { - SkillRewards = rewards, + Cached = rewards, ExpiresAt = expiresAt }; this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheSkillRewards {level.LevelId} - will expire in {expiresAt} Ä"); @@ -89,10 +90,10 @@ public void RemoveSkillRewards(GameLevel level) public List GetSkillRewards(GameLevel level, GameDatabaseContext database) { - CachedSkillRewards? cached = this._cachedSkillRewards.GetValueOrDefault(level.LevelId); + CachedData>? cached = this._cachedSkillRewards.GetValueOrDefault(level.LevelId); this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetSkillRewards {level.LevelId} - cached {cached} expires in {cached?.ExpiresAt} Ä"); - if (cached == null || cached.ExpiresAt < this._time.TimeProvider.Now) + if (this.HasCacheExpired(cached)) { List refreshed = database.GetSkillRewardsForLevel(level).ToList(); this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetSkillRewards {level.LevelId} - gotten from DB {refreshed} Ä"); @@ -101,7 +102,7 @@ public List GetSkillRewards(GameLevel level, GameDatabaseContex return refreshed; } - return cached.SkillRewards; + return cached!.Cached ?? []; // should never be null, but incase } #endregion @@ -112,7 +113,7 @@ public void CacheTags(GameLevel level, List tags) DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); this._cachedLevelTags[level.LevelId] = new() { - Tags = tags, + Cached = tags, ExpiresAt = expiresAt }; this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheTags {level.LevelId} - will expire in {expiresAt} Ä"); @@ -127,10 +128,10 @@ public void RemoveTags(GameLevel level) public List GetTags(GameLevel level, GameDatabaseContext database) { - CachedLevelTags? cached = this._cachedLevelTags.GetValueOrDefault(level.LevelId); + CachedData>? cached = this._cachedLevelTags.GetValueOrDefault(level.LevelId); this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetTags {level.LevelId} - cached {cached} expires in {cached?.ExpiresAt} Ä"); - if (cached == null || cached.ExpiresAt < this._time.TimeProvider.Now) + if (this.HasCacheExpired(cached)) { List refreshed = database.GetTagsForLevel(level).ToList(); this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetTags {level.LevelId} - gotten from DB {refreshed} Ä"); @@ -139,52 +140,133 @@ public List GetTags(GameLevel level, GameDatabaseContext database) return refreshed; } - return cached.Tags; + return cached!.Cached ?? []; } #endregion #region Own User Relations + private OwnUserRelations CreateUserRelations(GameUser source, GameUser target, GameDatabaseContext database) + { + return new() + { + IsHearted = database.IsUserFavouritedByUser(target, source), + }; + } + public void CacheOwnUserRelations(GameUser source, GameUser target, OwnUserRelations newData) { DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); this._cachedOwnUserRelations[source.UserId][target.UserId] = new() { - Relations = newData, + Cached = newData, ExpiresAt = expiresAt }; this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheOwnUserRelations s {source.UserId} t {target.UserId} - will expire in {expiresAt} Ä"); } + + // TODO: minimal chance of wrong cached value if refreshed from DB + public void UpdateUserHeart(GameUser source, GameUser target, bool newValue, GameDatabaseContext database) + { + OwnUserRelations existing = this.GetOwnUserRelations(source, target, database); + existing.IsHearted = newValue; + this.CacheOwnUserRelations(source, target, existing); + } + public OwnUserRelations GetOwnUserRelations(GameUser source, GameUser target, GameDatabaseContext database) { - CachedOwnUserRelations? cached = this._cachedOwnUserRelations.GetValueOrDefault(source.UserId)?.GetValueOrDefault(target.UserId); + CachedData? cached = this._cachedOwnUserRelations.GetValueOrDefault(source.UserId)?.GetValueOrDefault(target.UserId); this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnUserRelations s {source.UserId} t {target.UserId} - cached {cached} expires in {cached?.ExpiresAt} Ä"); - if (cached == null || cached.ExpiresAt < this._time.TimeProvider.Now) + if (this.HasCacheExpired(cached)) { - OwnUserRelations refreshed = new() - { - IsHearted = database.IsUserFavouritedByUser(target, source), - }; + OwnUserRelations refreshed = this.CreateUserRelations(source, target, database); this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnUserRelations s {source.UserId} t {target.UserId} - gotten from DB {refreshed} Ä"); this.CacheOwnUserRelations(source, target, refreshed); return refreshed; } - return cached.Relations; + return cached!.Cached!; } #endregion #region Own Level Relations + private OwnLevelRelations CreateLevelRelations(GameUser source, GameLevel target, GameDatabaseContext database) + { + return new() + { + IsHearted = database.IsLevelFavouritedByUser(target, source), + IsQueued = database.IsLevelQueuedByUser(target, source), + LevelRating = (int?)database.GetRatingByUser(target, source) ?? 0, + TotalPlayCount = database.GetTotalPlaysForLevelByUser(target, source), + TotalCompletionCount = database.GetTotalCompletionsForLevelByUser(target, source), + PhotoCount = database.GetTotalPhotosInLevelByUser(target, source), + }; + } + + public void UpdateLevelHeart(GameUser source, GameLevel target, bool newValue, GameDatabaseContext database) + { + OwnLevelRelations existing = this.GetOwnLevelRelations(source, target, database); + existing.IsHearted = newValue; + this.CacheOwnLevelRelations(source, target, existing); + } + + public void UpdateLevelQueue(GameUser source, GameLevel target, bool newValue, GameDatabaseContext database) + { + OwnLevelRelations existing = this.GetOwnLevelRelations(source, target, database); + existing.IsHearted = newValue; + this.CacheOwnLevelRelations(source, target, existing); + } + + public void DequeueAllLevels(GameUser source, GameDatabaseContext database) + { + foreach (KeyValuePair> relations in this._cachedOwnLevelRelations.GetValueOrDefault(source.UserId)?.ToArray() ?? []) + { + // will be refreshed later anyway; don't want too many DB calls here (e.g. user had 100 levels queued, this method would in that case send 600 DB queries) + if (this.HasCacheExpired(relations.Value)) continue; + + relations.Value.Cached!.IsQueued = false; + this._cachedOwnLevelRelations[source.UserId][relations.Key] = relations.Value; + } + } + + public void UpdateLevelRating(GameUser source, GameLevel target, int newRating, GameDatabaseContext database) + { + OwnLevelRelations existing = this.GetOwnLevelRelations(source, target, database); + existing.LevelRating = newRating; + this.CacheOwnLevelRelations(source, target, existing); + } + + public void IncrementLevelTotalPlays(GameUser source, GameLevel target, int incrementor, GameDatabaseContext database) + { + OwnLevelRelations existing = this.GetOwnLevelRelations(source, target, database); + existing.TotalPlayCount += incrementor; + this.CacheOwnLevelRelations(source, target, existing); + } + + public void IncrementLevelTotalCompletions(GameUser source, GameLevel target, int incrementor, GameDatabaseContext database) + { + OwnLevelRelations existing = this.GetOwnLevelRelations(source, target, database); + existing.TotalCompletionCount += incrementor; + this.CacheOwnLevelRelations(source, target, existing); + } + + public void IncrementLevelPhotos(GameUser source, GameLevel target, int incrementor, GameDatabaseContext database) + { + OwnLevelRelations existing = this.GetOwnLevelRelations(source, target, database); + existing.PhotoCount += incrementor; + this.CacheOwnLevelRelations(source, target, existing); + } + public void CacheOwnLevelRelations(GameUser source, GameLevel target, OwnLevelRelations newData) { DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); this._cachedOwnLevelRelations[source.UserId][target.LevelId] = new() { - Relations = newData, + Cached = newData, ExpiresAt = expiresAt }; this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheOwnLevelRelations s {source.UserId} t {target.LevelId} - will expire in {expiresAt} Ä"); @@ -192,28 +274,23 @@ public void CacheOwnLevelRelations(GameUser source, GameLevel target, OwnLevelRe public OwnLevelRelations GetOwnLevelRelations(GameUser source, GameLevel target, GameDatabaseContext database) { - CachedOwnLevelRelations? cached = this._cachedOwnLevelRelations.GetValueOrDefault(source.UserId)?.GetValueOrDefault(target.LevelId); + CachedData? cached = this._cachedOwnLevelRelations.GetValueOrDefault(source.UserId)?.GetValueOrDefault(target.LevelId); this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnLevelRelations s {source.UserId} t {target.LevelId} - cached {cached} expires in {cached?.ExpiresAt} Ä"); - if (cached == null || cached.ExpiresAt < this._time.TimeProvider.Now) + if (this.HasCacheExpired(cached)) { - OwnLevelRelations refreshed = new() - { - IsHearted = database.IsLevelFavouritedByUser(target, source), - IsQueued = database.IsLevelQueuedByUser(target, source), - LevelRating = (int?)database.GetRatingByUser(target, source) ?? 0, - TotalPlayCount = database.GetTotalPlaysForLevelByUser(target, source), - TotalCompletionCount = database.GetTotalCompletionsForLevelByUser(target, source), - PhotoCount = database.GetTotalPhotosInLevelByUser(target, source) - }; - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnLevelRelations s {source.UserId} t {target.LevelId} - gotten from DB {refreshed} Ä"); + OwnLevelRelations refreshed = this.CreateLevelRelations(source, target, database); + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnLevelRelations s {source.UserId} t {target.LevelId} - gotten from DB {refreshed} plays {refreshed.TotalPlayCount} Ä"); this.CacheOwnLevelRelations(source, target, refreshed); return refreshed; } - return cached.Relations; + return cached!.Cached!; } #endregion + + private bool HasCacheExpired(CachedData? cached) + => cached == null || cached.ExpiresAt < this._time.TimeProvider.Now; } \ No newline at end of file diff --git a/Refresh.Core/Types/Cache/CachedAssetData.cs b/Refresh.Core/Types/Cache/CachedAssetData.cs deleted file mode 100644 index 2f3cfdf80..000000000 --- a/Refresh.Core/Types/Cache/CachedAssetData.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Refresh.Database.Models.Assets; - -namespace Refresh.Core.Types.Cache; - -public class CachedAssetData -{ - public GameAsset? Asset { get; set; } // incase asset doesn't exist - public DateTimeOffset ExpiresAt { get; set; } -} \ No newline at end of file diff --git a/Refresh.Core/Types/Cache/CachedData.cs b/Refresh.Core/Types/Cache/CachedData.cs index 09269a175..2dcefd844 100644 --- a/Refresh.Core/Types/Cache/CachedData.cs +++ b/Refresh.Core/Types/Cache/CachedData.cs @@ -1,10 +1,8 @@ namespace Refresh.Core.Types.Cache; -#nullable disable - // TODO: use this to cache various data -public class CachedData +public class CachedData { - public TCachedData Cached { get; set; } + public TData? Cached { get; set; } public DateTimeOffset ExpiresAt { get; set; } } \ No newline at end of file diff --git a/Refresh.Core/Types/Cache/CachedLevelTags.cs b/Refresh.Core/Types/Cache/CachedLevelTags.cs deleted file mode 100644 index 9715b6b92..000000000 --- a/Refresh.Core/Types/Cache/CachedLevelTags.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Refresh.Database.Models.Levels; - -namespace Refresh.Core.Types.Cache; - -public class CachedLevelTags -{ - public List Tags { get; set; } = []; - public DateTimeOffset ExpiresAt { get; set; } -} \ No newline at end of file diff --git a/Refresh.Core/Types/Cache/CachedOwnLevelRelations.cs b/Refresh.Core/Types/Cache/CachedOwnLevelRelations.cs deleted file mode 100644 index 0dfe5fd29..000000000 --- a/Refresh.Core/Types/Cache/CachedOwnLevelRelations.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Refresh.Core.Types.Relations; - -namespace Refresh.Core.Types.Cache; - -public class CachedOwnLevelRelations -{ - public OwnLevelRelations Relations { get; set; } = null!; - public DateTimeOffset ExpiresAt { get; set; } -} \ No newline at end of file diff --git a/Refresh.Core/Types/Cache/CachedOwnUserRelations.cs b/Refresh.Core/Types/Cache/CachedOwnUserRelations.cs deleted file mode 100644 index 3e18b7df7..000000000 --- a/Refresh.Core/Types/Cache/CachedOwnUserRelations.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Refresh.Core.Types.Relations; - -namespace Refresh.Core.Types.Cache; - -public class CachedOwnUserRelations -{ - public OwnUserRelations Relations { get; set; } = null!; - public DateTimeOffset ExpiresAt { get; set; } -} \ No newline at end of file diff --git a/Refresh.Core/Types/Cache/CachedSkillRewards.cs b/Refresh.Core/Types/Cache/CachedSkillRewards.cs deleted file mode 100644 index a66a8b48b..000000000 --- a/Refresh.Core/Types/Cache/CachedSkillRewards.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Refresh.Database.Models.Levels; - -namespace Refresh.Core.Types.Cache; - -public class CachedSkillRewards -{ - public List SkillRewards { get; set; } = []; - public DateTimeOffset ExpiresAt { get; set; } -} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs index e18bf3636..9b8358403 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs @@ -163,7 +163,7 @@ public ApiResponse GetLevelRelationsOfUser(Req [RateLimitSettings(CommonRelationEndpointLimits.TimeoutDuration, CommonRelationEndpointLimits.RequestAmount, CommonRelationEndpointLimits.BlockDuration, CommonRelationEndpointLimits.RequestBucket)] public ApiOkResponse FavouriteLevel(RequestContext context, GameDatabaseContext database, GameUser user, - [DocSummary("The ID of the level")] int id) + [DocSummary("The ID of the level")] int id, DataContext dataContext) { GameLevel? level = database.GetLevelById(id); if (level == null) return ApiNotFoundError.LevelMissingError; @@ -178,7 +178,7 @@ public ApiOkResponse FavouriteLevel(RequestContext context, GameDatabaseContext [RateLimitSettings(CommonRelationEndpointLimits.TimeoutDuration, CommonRelationEndpointLimits.RequestAmount, CommonRelationEndpointLimits.BlockDuration, CommonRelationEndpointLimits.RequestBucket)] public ApiOkResponse UnheartLevel(RequestContext context, GameDatabaseContext database, GameUser user, - [DocSummary("The ID of the level")] int id) + [DocSummary("The ID of the level")] int id, DataContext dataContext) { GameLevel? level = database.GetLevelById(id); if (level == null) return ApiNotFoundError.LevelMissingError; @@ -193,7 +193,7 @@ public ApiOkResponse UnheartLevel(RequestContext context, GameDatabaseContext da [RateLimitSettings(CommonRelationEndpointLimits.TimeoutDuration, CommonRelationEndpointLimits.RequestAmount, CommonRelationEndpointLimits.BlockDuration, CommonRelationEndpointLimits.RequestBucket)] public ApiOkResponse QueueLevel(RequestContext context, GameDatabaseContext database, GameUser user, - [DocSummary("The ID of the level")] int id) + [DocSummary("The ID of the level")] int id, DataContext dataContext) { GameLevel? level = database.GetLevelById(id); if (level == null) return ApiNotFoundError.LevelMissingError; @@ -214,7 +214,7 @@ public ApiOkResponse QueueLevel(RequestContext context, GameDatabaseContext data [RateLimitSettings(CommonRelationEndpointLimits.TimeoutDuration, CommonRelationEndpointLimits.RequestAmount, CommonRelationEndpointLimits.BlockDuration, CommonRelationEndpointLimits.RequestBucket)] public ApiOkResponse DequeueLevel(RequestContext context, GameDatabaseContext database, GameUser user, - [DocSummary("The ID of the level")] int id) + [DocSummary("The ID of the level")] int id, DataContext dataContext) { GameLevel? level = database.GetLevelById(id); if (level == null) return ApiNotFoundError.LevelMissingError; From bb030980ef6d02bcc155c5fce8f36b296e85573f Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 11 Mar 2026 13:32:36 +0100 Subject: [PATCH 04/15] CacheService improvements --- Refresh.Core/Services/CacheService.cs | 184 +++++++++--------- Refresh.Core/Types/Cache/CachedData.cs | 9 +- Refresh.Core/Types/Cache/CachedReturn.cs | 13 ++ .../Endpoints/LevelApiEndpoints.cs | 13 +- .../Endpoints/UserApiEndpoints.cs | 2 + .../Endpoints/Handshake/MetadataEndpoints.cs | 6 +- .../Endpoints/RelationEndpoints.cs | 21 +- 7 files changed, 143 insertions(+), 105 deletions(-) create mode 100644 Refresh.Core/Types/Cache/CachedReturn.cs diff --git a/Refresh.Core/Services/CacheService.cs b/Refresh.Core/Services/CacheService.cs index 37b58de0e..56f052817 100644 --- a/Refresh.Core/Services/CacheService.cs +++ b/Refresh.Core/Services/CacheService.cs @@ -14,7 +14,7 @@ namespace Refresh.Core.Services; public class CacheService : EndpointService { private readonly TimeProviderService _time; - private readonly Dictionary> _cachedAssetData = []; // hash -> data + private readonly Dictionary> _cachedAssetData = []; // hash -> data private readonly Dictionary>> _cachedSkillRewards = []; // level ID -> data private readonly Dictionary>> _cachedLevelTags = []; // level ID -> data @@ -35,20 +35,16 @@ public CacheService(Logger logger, TimeProviderService time) : base(logger) public void CacheAsset(string hash, GameAsset? asset) { DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(AssetCacheDurationSeconds); - this._cachedAssetData[hash] = new() - { - Cached = asset, - ExpiresAt = expiresAt - }; + this._cachedAssetData[hash] = new(asset, expiresAt); this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheAsset {hash} - will expire in {expiresAt} Ä"); } public GameAsset? GetAssetInfo(string hash, GameDatabaseContext database) { - CachedData? cached = this._cachedAssetData.GetValueOrDefault(hash); - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetAssetInfo {hash} - cached {cached} expires in {cached?.ExpiresAt} Ä"); + CachedData? fromCache = this._cachedAssetData.GetValueOrDefault(hash); + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetAssetInfo {hash} - cached {fromCache} expires in {fromCache?.ExpiresAt} Ä"); - if (this.HasCacheExpired(cached)) + if (this.HasCacheExpired(fromCache)) { GameAsset? refreshed = database.GetAssetFromHash(hash); this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetAssetInfo {hash} - gotten from DB {refreshed} Ä"); @@ -59,7 +55,7 @@ public void CacheAsset(string hash, GameAsset? asset) return refreshed; } - return cached!.Cached; + return fromCache?.Content; } #endregion @@ -75,11 +71,7 @@ public void RemoveLevelData(GameLevel level) public void CacheSkillRewards(GameLevel level, List rewards) { DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); - this._cachedSkillRewards[level.LevelId] = new() - { - Cached = rewards, - ExpiresAt = expiresAt - }; + this._cachedSkillRewards[level.LevelId] = new(rewards, expiresAt); this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheSkillRewards {level.LevelId} - will expire in {expiresAt} Ä"); } @@ -90,10 +82,10 @@ public void RemoveSkillRewards(GameLevel level) public List GetSkillRewards(GameLevel level, GameDatabaseContext database) { - CachedData>? cached = this._cachedSkillRewards.GetValueOrDefault(level.LevelId); - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetSkillRewards {level.LevelId} - cached {cached} expires in {cached?.ExpiresAt} Ä"); + CachedData>? fromCache = this._cachedSkillRewards.GetValueOrDefault(level.LevelId); + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetSkillRewards {level.LevelId} - cached {fromCache} expires in {fromCache?.ExpiresAt} Ä"); - if (this.HasCacheExpired(cached)) + if (this.HasCacheExpired(fromCache)) { List refreshed = database.GetSkillRewardsForLevel(level).ToList(); this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetSkillRewards {level.LevelId} - gotten from DB {refreshed} Ä"); @@ -102,7 +94,7 @@ public List GetSkillRewards(GameLevel level, GameDatabaseContex return refreshed; } - return cached!.Cached ?? []; // should never be null, but incase + return fromCache!.Content; } #endregion @@ -111,11 +103,7 @@ public List GetSkillRewards(GameLevel level, GameDatabaseContex public void CacheTags(GameLevel level, List tags) { DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); - this._cachedLevelTags[level.LevelId] = new() - { - Cached = tags, - ExpiresAt = expiresAt - }; + this._cachedLevelTags[level.LevelId] = new(tags, expiresAt); this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheTags {level.LevelId} - will expire in {expiresAt} Ä"); } @@ -128,10 +116,10 @@ public void RemoveTags(GameLevel level) public List GetTags(GameLevel level, GameDatabaseContext database) { - CachedData>? cached = this._cachedLevelTags.GetValueOrDefault(level.LevelId); - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetTags {level.LevelId} - cached {cached} expires in {cached?.ExpiresAt} Ä"); + CachedData>? fromCache = this._cachedLevelTags.GetValueOrDefault(level.LevelId); + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetTags {level.LevelId} - cached {fromCache} expires in {fromCache?.ExpiresAt} Ä"); - if (this.HasCacheExpired(cached)) + if (this.HasCacheExpired(fromCache)) { List refreshed = database.GetTagsForLevel(level).ToList(); this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetTags {level.LevelId} - gotten from DB {refreshed} Ä"); @@ -140,7 +128,7 @@ public List GetTags(GameLevel level, GameDatabaseContext database) return refreshed; } - return cached!.Cached ?? []; + return fromCache!.Content; } #endregion @@ -158,28 +146,21 @@ private OwnUserRelations CreateUserRelations(GameUser source, GameUser target, G public void CacheOwnUserRelations(GameUser source, GameUser target, OwnUserRelations newData) { DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); - this._cachedOwnUserRelations[source.UserId][target.UserId] = new() - { - Cached = newData, - ExpiresAt = expiresAt - }; + this._cachedOwnUserRelations[source.UserId][target.UserId] = new(newData, expiresAt); this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheOwnUserRelations s {source.UserId} t {target.UserId} - will expire in {expiresAt} Ä"); } - // TODO: minimal chance of wrong cached value if refreshed from DB - public void UpdateUserHeart(GameUser source, GameUser target, bool newValue, GameDatabaseContext database) + public void RemoveOwnUserRelations(GameUser source, GameUser target) { - OwnUserRelations existing = this.GetOwnUserRelations(source, target, database); - existing.IsHearted = newValue; - this.CacheOwnUserRelations(source, target, existing); + this._cachedOwnUserRelations.GetValueOrDefault(source.UserId)?.Remove(target.UserId); } public OwnUserRelations GetOwnUserRelations(GameUser source, GameUser target, GameDatabaseContext database) { - CachedData? cached = this._cachedOwnUserRelations.GetValueOrDefault(source.UserId)?.GetValueOrDefault(target.UserId); - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnUserRelations s {source.UserId} t {target.UserId} - cached {cached} expires in {cached?.ExpiresAt} Ä"); + CachedData? fromCache = this._cachedOwnUserRelations.GetValueOrDefault(source.UserId)?.GetValueOrDefault(target.UserId); + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnUserRelations s {source.UserId} t {target.UserId} - cached {fromCache} expires in {fromCache?.ExpiresAt} Ä"); - if (this.HasCacheExpired(cached)) + if (this.HasCacheExpired(fromCache)) { OwnUserRelations refreshed = this.CreateUserRelations(source, target, database); this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnUserRelations s {source.UserId} t {target.UserId} - gotten from DB {refreshed} Ä"); @@ -188,7 +169,14 @@ public OwnUserRelations GetOwnUserRelations(GameUser source, GameUser target, Ga return refreshed; } - return cached!.Cached!; + return fromCache!.Content; + } + + public void UpdateUserHeartedStatusByUser(GameUser source, GameUser target, bool newValue, GameDatabaseContext database) + { + OwnUserRelations fromCache = this.GetOwnUserRelations(source, target, database); + fromCache.IsHearted = newValue; + this.CacheOwnUserRelations(source, target, fromCache); } #endregion @@ -207,86 +195,100 @@ private OwnLevelRelations CreateLevelRelations(GameUser source, GameLevel target }; } - public void UpdateLevelHeart(GameUser source, GameLevel target, bool newValue, GameDatabaseContext database) + public void CacheOwnLevelRelations(GameUser source, GameLevel target, OwnLevelRelations newData) { - OwnLevelRelations existing = this.GetOwnLevelRelations(source, target, database); - existing.IsHearted = newValue; - this.CacheOwnLevelRelations(source, target, existing); + DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); + this._cachedOwnLevelRelations[source.UserId][target.LevelId] = new(newData, expiresAt); + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheOwnLevelRelations s {source.UserId} t {target.LevelId} - will expire in {expiresAt} Ä"); } - public void UpdateLevelQueue(GameUser source, GameLevel target, bool newValue, GameDatabaseContext database) + public void RemoveOwnLevelRelations(GameUser source, GameLevel target) { - OwnLevelRelations existing = this.GetOwnLevelRelations(source, target, database); - existing.IsHearted = newValue; - this.CacheOwnLevelRelations(source, target, existing); + this._cachedOwnLevelRelations.GetValueOrDefault(source.UserId)?.Remove(target.LevelId); } - public void DequeueAllLevels(GameUser source, GameDatabaseContext database) + public CachedReturn GetOwnLevelRelations(GameUser source, GameLevel target, GameDatabaseContext database) { - foreach (KeyValuePair> relations in this._cachedOwnLevelRelations.GetValueOrDefault(source.UserId)?.ToArray() ?? []) + CachedData? fromCache = this._cachedOwnLevelRelations.GetValueOrDefault(source.UserId)?.GetValueOrDefault(target.LevelId); + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnLevelRelations s {source.UserId} t {target.LevelId} - cached {fromCache} expires in {fromCache} Ä"); + + if (this.HasCacheExpired(fromCache)) { - // will be refreshed later anyway; don't want too many DB calls here (e.g. user had 100 levels queued, this method would in that case send 600 DB queries) - if (this.HasCacheExpired(relations.Value)) continue; + OwnLevelRelations refreshed = this.CreateLevelRelations(source, target, database); + this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnLevelRelations s {source.UserId} t {target.LevelId} - gotten from DB {refreshed} plays {refreshed.TotalPlayCount} Ä"); - relations.Value.Cached!.IsQueued = false; - this._cachedOwnLevelRelations[source.UserId][relations.Key] = relations.Value; + this.CacheOwnLevelRelations(source, target, refreshed); + return new(refreshed, true); } + + return new(fromCache!.Content, false); } - public void UpdateLevelRating(GameUser source, GameLevel target, int newRating, GameDatabaseContext database) + public void UpdateLevelHeartedStatusByUser(GameUser source, GameLevel target, bool newValue, GameDatabaseContext database) { - OwnLevelRelations existing = this.GetOwnLevelRelations(source, target, database); - existing.LevelRating = newRating; - this.CacheOwnLevelRelations(source, target, existing); + CachedReturn fromCache = this.GetOwnLevelRelations(source, target, database); + if (fromCache.WasRefreshed) return; // value is already up-to-date + + fromCache.Content.IsHearted = newValue; + this.CacheOwnLevelRelations(source, target, fromCache.Content); } - public void IncrementLevelTotalPlays(GameUser source, GameLevel target, int incrementor, GameDatabaseContext database) + public void UpdateLevelQueuedStatusByUser(GameUser source, GameLevel target, bool newValue, GameDatabaseContext database) { - OwnLevelRelations existing = this.GetOwnLevelRelations(source, target, database); - existing.TotalPlayCount += incrementor; - this.CacheOwnLevelRelations(source, target, existing); + CachedReturn fromCache = this.GetOwnLevelRelations(source, target, database); + if (fromCache.WasRefreshed) return; // value is already up-to-date + + fromCache.Content.IsQueued = newValue; + this.CacheOwnLevelRelations(source, target, fromCache.Content); } - public void IncrementLevelTotalCompletions(GameUser source, GameLevel target, int incrementor, GameDatabaseContext database) + public void DequeueAllLevelsByUser(GameUser source) { - OwnLevelRelations existing = this.GetOwnLevelRelations(source, target, database); - existing.TotalCompletionCount += incrementor; - this.CacheOwnLevelRelations(source, target, existing); + foreach (KeyValuePair> relations in this._cachedOwnLevelRelations.GetValueOrDefault(source.UserId)?.ToArray() ?? []) + { + // will be refreshed later anyway; don't want too many DB calls here (e.g. user had 100 levels queued, this method would in that case send 600 DB queries) + if (this.HasCacheExpired(relations.Value)) continue; + + relations.Value.Content.IsQueued = false; + this.Logger.LogDebug(BunkumCategory.UserLevels, $"DequeueAllLevels level {relations.Key} was queued by {source}"); + this._cachedOwnLevelRelations[source.UserId][relations.Key] = relations.Value; + } } - public void IncrementLevelPhotos(GameUser source, GameLevel target, int incrementor, GameDatabaseContext database) + public void UpdateLevelRatingByUser(GameUser source, GameLevel target, int newRating, GameDatabaseContext database) { - OwnLevelRelations existing = this.GetOwnLevelRelations(source, target, database); - existing.PhotoCount += incrementor; - this.CacheOwnLevelRelations(source, target, existing); + CachedReturn fromCache = this.GetOwnLevelRelations(source, target, database); + if (fromCache.WasRefreshed) return; // value is already up-to-date + + fromCache.Content.LevelRating = newRating; + this.CacheOwnLevelRelations(source, target, fromCache.Content); } - public void CacheOwnLevelRelations(GameUser source, GameLevel target, OwnLevelRelations newData) + public void IncrementLevelTotalPlaysByUser(GameUser source, GameLevel target, int incrementor, GameDatabaseContext database) { - DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); - this._cachedOwnLevelRelations[source.UserId][target.LevelId] = new() - { - Cached = newData, - ExpiresAt = expiresAt - }; - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheOwnLevelRelations s {source.UserId} t {target.LevelId} - will expire in {expiresAt} Ä"); + CachedReturn fromCache = this.GetOwnLevelRelations(source, target, database); + if (fromCache.WasRefreshed) return; // value is already up-to-date + + fromCache.Content.TotalPlayCount += incrementor; + this.CacheOwnLevelRelations(source, target, fromCache.Content); } - public OwnLevelRelations GetOwnLevelRelations(GameUser source, GameLevel target, GameDatabaseContext database) + public void IncrementLevelTotalCompletionsByUser(GameUser source, GameLevel target, int incrementor, GameDatabaseContext database) { - CachedData? cached = this._cachedOwnLevelRelations.GetValueOrDefault(source.UserId)?.GetValueOrDefault(target.LevelId); - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnLevelRelations s {source.UserId} t {target.LevelId} - cached {cached} expires in {cached?.ExpiresAt} Ä"); + CachedReturn fromCache = this.GetOwnLevelRelations(source, target, database); + if (fromCache.WasRefreshed) return; // value is already up-to-date - if (this.HasCacheExpired(cached)) - { - OwnLevelRelations refreshed = this.CreateLevelRelations(source, target, database); - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnLevelRelations s {source.UserId} t {target.LevelId} - gotten from DB {refreshed} plays {refreshed.TotalPlayCount} Ä"); + fromCache.Content.TotalCompletionCount += incrementor; + this.CacheOwnLevelRelations(source, target, fromCache.Content); + } - this.CacheOwnLevelRelations(source, target, refreshed); - return refreshed; - } + public void IncrementLevelPhotosByUser(GameUser source, GameLevel target, int incrementor, GameDatabaseContext database) + { + CachedReturn fromCache = this.GetOwnLevelRelations(source, target, database); + if (fromCache.WasRefreshed) return; // value is already up-to-date - return cached!.Cached!; + fromCache.Content.PhotoCount += incrementor; + this.CacheOwnLevelRelations(source, target, fromCache.Content); } #endregion diff --git a/Refresh.Core/Types/Cache/CachedData.cs b/Refresh.Core/Types/Cache/CachedData.cs index 2dcefd844..76cb0e5ce 100644 --- a/Refresh.Core/Types/Cache/CachedData.cs +++ b/Refresh.Core/Types/Cache/CachedData.cs @@ -1,8 +1,13 @@ namespace Refresh.Core.Types.Cache; -// TODO: use this to cache various data public class CachedData { - public TData? Cached { get; set; } + public TData Content { get; set; } public DateTimeOffset ExpiresAt { get; set; } + + public CachedData(TData content, DateTimeOffset expiresAt) + { + Content = content; + ExpiresAt = expiresAt; + } } \ No newline at end of file diff --git a/Refresh.Core/Types/Cache/CachedReturn.cs b/Refresh.Core/Types/Cache/CachedReturn.cs new file mode 100644 index 000000000..2087351ef --- /dev/null +++ b/Refresh.Core/Types/Cache/CachedReturn.cs @@ -0,0 +1,13 @@ +namespace Refresh.Core.Types.Cache; + +public class CachedReturn +{ + public TData Content { get; set; } + public bool WasRefreshed { get; set; } + + public CachedReturn(TData content, bool wasRefreshed) + { + Content = content; + WasRefreshed = wasRefreshed; + } +} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs index 9b8358403..f75c7badb 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs @@ -158,7 +158,7 @@ public ApiResponse GetLevelRelationsOfUser(Req } [ApiV3Endpoint("levels/id/{id}/heart", HttpMethods.Post)] - [DocSummary("Adds a specific level by it's ID to your hearted levels")] + [DocSummary("Adds a specific level by its ID to your hearted levels")] [DocError(typeof(ApiNotFoundError), ApiNotFoundError.LevelMissingErrorWhen)] [RateLimitSettings(CommonRelationEndpointLimits.TimeoutDuration, CommonRelationEndpointLimits.RequestAmount, CommonRelationEndpointLimits.BlockDuration, CommonRelationEndpointLimits.RequestBucket)] @@ -169,11 +169,12 @@ public ApiOkResponse FavouriteLevel(RequestContext context, GameDatabaseContext if (level == null) return ApiNotFoundError.LevelMissingError; database.FavouriteLevel(level, user); + dataContext.Cache.UpdateLevelHeartedStatusByUser(user, level, true, database); return new ApiOkResponse(); } [ApiV3Endpoint("levels/id/{id}/unheart", HttpMethods.Post)] - [DocSummary("Removes a specific level by it's ID from your hearted levels")] + [DocSummary("Removes a specific level by its ID from your hearted levels")] [DocError(typeof(ApiNotFoundError), ApiNotFoundError.LevelMissingErrorWhen)] [RateLimitSettings(CommonRelationEndpointLimits.TimeoutDuration, CommonRelationEndpointLimits.RequestAmount, CommonRelationEndpointLimits.BlockDuration, CommonRelationEndpointLimits.RequestBucket)] @@ -184,11 +185,12 @@ public ApiOkResponse UnheartLevel(RequestContext context, GameDatabaseContext da if (level == null) return ApiNotFoundError.LevelMissingError; database.UnfavouriteLevel(level, user); + dataContext.Cache.UpdateLevelHeartedStatusByUser(user, level, false, database); return new ApiOkResponse(); } [ApiV3Endpoint("levels/id/{id}/queue", HttpMethods.Post)] - [DocSummary("Adds a specific level by it's ID to your queue")] + [DocSummary("Adds a specific level by its ID to your queue")] [DocError(typeof(ApiNotFoundError), ApiNotFoundError.LevelMissingErrorWhen)] [RateLimitSettings(CommonRelationEndpointLimits.TimeoutDuration, CommonRelationEndpointLimits.RequestAmount, CommonRelationEndpointLimits.BlockDuration, CommonRelationEndpointLimits.RequestBucket)] @@ -199,6 +201,7 @@ public ApiOkResponse QueueLevel(RequestContext context, GameDatabaseContext data if (level == null) return ApiNotFoundError.LevelMissingError; bool success = database.QueueLevel(level, user); + dataContext.Cache.UpdateLevelQueuedStatusByUser(user, level, true, database); // Only give pin if the level was queued without having already been queued. // Won't protect against spam, but this way the pin objective is more accurately implemented. @@ -209,7 +212,7 @@ public ApiOkResponse QueueLevel(RequestContext context, GameDatabaseContext data } [ApiV3Endpoint("levels/id/{id}/dequeue", HttpMethods.Post)] - [DocSummary("Removes a specific level by it's ID from your queue")] + [DocSummary("Removes a specific level by its ID from your queue")] [DocError(typeof(ApiNotFoundError), ApiNotFoundError.LevelMissingErrorWhen)] [RateLimitSettings(CommonRelationEndpointLimits.TimeoutDuration, CommonRelationEndpointLimits.RequestAmount, CommonRelationEndpointLimits.BlockDuration, CommonRelationEndpointLimits.RequestBucket)] @@ -220,6 +223,7 @@ public ApiOkResponse DequeueLevel(RequestContext context, GameDatabaseContext da if (level == null) return ApiNotFoundError.LevelMissingError; database.DequeueLevel(level, user); + dataContext.Cache.UpdateLevelQueuedStatusByUser(user, level, false, database); return new ApiOkResponse(); } @@ -231,6 +235,7 @@ public ApiOkResponse ClearQueuedLevels(RequestContext context, GameDatabaseConte IDataStore dataStore, GameUser user, DataContext dataContext) { database.ClearQueue(user); + dataContext.Cache.DequeueAllLevelsByUser(user); return new ApiOkResponse(); } } \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs index d53c3b6c1..76eedc8c5 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs @@ -55,6 +55,7 @@ public ApiOkResponse HeartUser(RequestContext context, GameDatabaseContext datab if(target == null) return ApiNotFoundError.UserMissingError; bool success = database.FavouriteUser(target, user); + dataContext.Cache.UpdateUserHeartedStatusByUser(user, target, true, database); // Only give pin if the user was hearted without having already been hearted. // Won't protect against spam, but this way the pin objective is more accurately implemented. @@ -77,6 +78,7 @@ public ApiOkResponse UnheartUser(RequestContext context, GameDatabaseContext dat if(target == null) return ApiNotFoundError.UserMissingError; database.UnfavouriteUser(target, user); + dataContext.Cache.UpdateUserHeartedStatusByUser(user, target, false, database); return new ApiOkResponse(); } diff --git a/Refresh.Interfaces.Game/Endpoints/Handshake/MetadataEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Handshake/MetadataEndpoints.cs index 64df00cd3..959fbfcdf 100644 --- a/Refresh.Interfaces.Game/Endpoints/Handshake/MetadataEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Handshake/MetadataEndpoints.cs @@ -10,6 +10,7 @@ using Refresh.Common.Time; using Refresh.Core.Authentication.Permission; using Refresh.Core.Configuration; +using Refresh.Core.Types.Data; using Refresh.Database; using Refresh.Database.Models.Levels; using Refresh.Database.Models.Users; @@ -47,7 +48,7 @@ public SerializedPrivacySettings SetPrivacySettings(RequestContext context, Seri [GameEndpoint("npdata", ContentType.Xml, HttpMethods.Post)] [RateLimitSettings(480, 8, 420, "game-npdata")] - public Response SetFriendData(RequestContext context, GameUser user, GameDatabaseContext database, SerializedFriendData body) + public Response SetFriendData(RequestContext context, GameUser user, GameDatabaseContext database, SerializedFriendData body, DataContext dataContext) { IEnumerable friends = body.FriendsList.Names .Take(128) // should be way more than enough - we'll see if this becomes a problem @@ -55,7 +56,10 @@ public Response SetFriendData(RequestContext context, GameUser user, GameDatabas .Where(u => u != null)!; foreach (GameUser userToFavourite in friends) + { database.FavouriteUser(userToFavourite, user); + dataContext.Cache.RemoveOwnUserRelations(user, userToFavourite); // really don't want to refresh the relations of up to 128 users in the worst case + } return OK; } diff --git a/Refresh.Interfaces.Game/Endpoints/RelationEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/RelationEndpoints.cs index c5f6459d1..aba0c3f61 100644 --- a/Refresh.Interfaces.Game/Endpoints/RelationEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/RelationEndpoints.cs @@ -24,7 +24,7 @@ public class RelationEndpoints : EndpointGroup [RateLimitSettings(CommonRelationEndpointLimits.TimeoutDuration, CommonRelationEndpointLimits.RequestAmount, CommonRelationEndpointLimits.BlockDuration, CommonRelationEndpointLimits.RequestBucket)] public Response FavouriteLevel(RequestContext context, GameDatabaseContext database, GameUser user, string slotType, - int id, GameServerConfig config) + int id, GameServerConfig config, DataContext dataContext) { if (user.IsWriteBlocked(config)) return context.IsPSP() ? OK : Unauthorized; // See comment below @@ -35,6 +35,7 @@ public Response FavouriteLevel(RequestContext context, GameDatabaseContext datab if (level == null) return context.IsPSP() ? OK : NotFound; database.FavouriteLevel(level, user); + dataContext.Cache.UpdateLevelHeartedStatusByUser(user, level, true, database); return OK; } @@ -43,7 +44,7 @@ public Response FavouriteLevel(RequestContext context, GameDatabaseContext datab [RateLimitSettings(CommonRelationEndpointLimits.TimeoutDuration, CommonRelationEndpointLimits.RequestAmount, CommonRelationEndpointLimits.BlockDuration, CommonRelationEndpointLimits.RequestBucket)] public Response UnfavouriteLevel(RequestContext context, GameDatabaseContext database, GameUser user, - string slotType, int id, GameServerConfig config) + string slotType, int id, GameServerConfig config, DataContext dataContext) { if (user.IsWriteBlocked(config)) return context.IsPSP() ? OK : Unauthorized; // See comment below @@ -54,6 +55,7 @@ public Response UnfavouriteLevel(RequestContext context, GameDatabaseContext dat if (level == null) return context.IsPSP() ? OK : NotFound; database.UnfavouriteLevel(level, user); + dataContext.Cache.UpdateLevelHeartedStatusByUser(user, level, false, database); return OK; } @@ -62,7 +64,7 @@ public Response UnfavouriteLevel(RequestContext context, GameDatabaseContext dat [RateLimitSettings(CommonRelationEndpointLimits.TimeoutDuration, CommonRelationEndpointLimits.RequestAmount, CommonRelationEndpointLimits.BlockDuration, CommonRelationEndpointLimits.RequestBucket)] public Response FavouriteUser(RequestContext context, GameDatabaseContext database, GameUser user, string username, - GameServerConfig config) + GameServerConfig config, DataContext dataContext) { if (user.IsWriteBlocked(config)) return context.IsPSP() ? OK : Unauthorized; // See comment below @@ -73,6 +75,7 @@ public Response FavouriteUser(RequestContext context, GameDatabaseContext databa if (userToFavourite == null) return context.IsPSP() ? OK : NotFound; database.FavouriteUser(userToFavourite, user); + dataContext.Cache.UpdateUserHeartedStatusByUser(user, userToFavourite, true, database); return OK; } @@ -81,7 +84,7 @@ public Response FavouriteUser(RequestContext context, GameDatabaseContext databa [RateLimitSettings(CommonRelationEndpointLimits.TimeoutDuration, CommonRelationEndpointLimits.RequestAmount, CommonRelationEndpointLimits.BlockDuration, CommonRelationEndpointLimits.RequestBucket)] public Response UnfavouriteUser(RequestContext context, GameDatabaseContext database, GameUser user, - string username, GameServerConfig config) + string username, GameServerConfig config, DataContext dataContext) { if (user.IsWriteBlocked(config)) return context.IsPSP() ? OK : Unauthorized; // See comment below @@ -92,6 +95,7 @@ public Response UnfavouriteUser(RequestContext context, GameDatabaseContext data if (userToFavourite == null) return context.IsPSP() ? OK : NotFound; database.UnfavouriteUser(userToFavourite, user); + dataContext.Cache.UpdateUserHeartedStatusByUser(user, userToFavourite, false, database); return OK; } @@ -116,12 +120,13 @@ public Response UnfavouriteUser(RequestContext context, GameDatabaseContext data [RequireEmailVerified] [RateLimitSettings(CommonRelationEndpointLimits.TimeoutDuration, CommonRelationEndpointLimits.RequestAmount, CommonRelationEndpointLimits.BlockDuration, CommonRelationEndpointLimits.RequestBucket)] - public Response QueueLevel(RequestContext context, GameDatabaseContext database, GameUser user, string slotType, int id) + public Response QueueLevel(RequestContext context, GameDatabaseContext database, GameUser user, string slotType, int id, DataContext dataContext) { GameLevel? level = database.GetLevelByIdAndType(slotType, id); if (level == null) return NotFound; database.QueueLevel(level, user); + dataContext.Cache.UpdateLevelQueuedStatusByUser(user, level, true, database); return OK; } @@ -129,12 +134,13 @@ public Response QueueLevel(RequestContext context, GameDatabaseContext database, [RequireEmailVerified] [RateLimitSettings(CommonRelationEndpointLimits.TimeoutDuration, CommonRelationEndpointLimits.RequestAmount, CommonRelationEndpointLimits.BlockDuration, CommonRelationEndpointLimits.RequestBucket)] - public Response DequeueLevel(RequestContext context, GameDatabaseContext database, GameUser user, string slotType, int id) + public Response DequeueLevel(RequestContext context, GameDatabaseContext database, GameUser user, string slotType, int id, DataContext dataContext) { GameLevel? level = database.GetLevelByIdAndType(slotType, id); if (level == null) return NotFound; database.DequeueLevel(level, user); + dataContext.Cache.UpdateLevelQueuedStatusByUser(user, level, false, database); return OK; } @@ -142,9 +148,10 @@ public Response DequeueLevel(RequestContext context, GameDatabaseContext databas [RequireEmailVerified] [RateLimitSettings(CommonRelationEndpointLimits.TimeoutDuration, CommonRelationEndpointLimits.RequestAmount, CommonRelationEndpointLimits.BlockDuration, CommonRelationEndpointLimits.RequestBucket)] - public Response ClearQueue(RequestContext context, GameDatabaseContext database, GameUser user) + public Response ClearQueue(RequestContext context, GameDatabaseContext database, GameUser user, DataContext dataContext) { database.ClearQueue(user); + dataContext.Cache.DequeueAllLevelsByUser(user); return OK; } From 21823e42e8c5121581c7fb1fdb16129cc2fe1d44 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 11 Mar 2026 13:57:54 +0100 Subject: [PATCH 05/15] a few things i forgot --- Refresh.Core/Services/CacheService.cs | 51 ++++++++----------- .../ApiGameLevelOwnRelationsResponse.cs | 2 +- 2 files changed, 23 insertions(+), 30 deletions(-) diff --git a/Refresh.Core/Services/CacheService.cs b/Refresh.Core/Services/CacheService.cs index 56f052817..31071b9f3 100644 --- a/Refresh.Core/Services/CacheService.cs +++ b/Refresh.Core/Services/CacheService.cs @@ -39,6 +39,7 @@ public void CacheAsset(string hash, GameAsset? asset) this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheAsset {hash} - will expire in {expiresAt} Ä"); } + // currently not necessary to return these with CachedReturn... public GameAsset? GetAssetInfo(string hash, GameDatabaseContext database) { CachedData? fromCache = this._cachedAssetData.GetValueOrDefault(hash); @@ -135,14 +136,6 @@ public List GetTags(GameLevel level, GameDatabaseContext database) #region Own User Relations - private OwnUserRelations CreateUserRelations(GameUser source, GameUser target, GameDatabaseContext database) - { - return new() - { - IsHearted = database.IsUserFavouritedByUser(target, source), - }; - } - public void CacheOwnUserRelations(GameUser source, GameUser target, OwnUserRelations newData) { DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); @@ -155,46 +148,38 @@ public void RemoveOwnUserRelations(GameUser source, GameUser target) this._cachedOwnUserRelations.GetValueOrDefault(source.UserId)?.Remove(target.UserId); } - public OwnUserRelations GetOwnUserRelations(GameUser source, GameUser target, GameDatabaseContext database) + public CachedReturn GetOwnUserRelations(GameUser source, GameUser target, GameDatabaseContext database) { CachedData? fromCache = this._cachedOwnUserRelations.GetValueOrDefault(source.UserId)?.GetValueOrDefault(target.UserId); this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnUserRelations s {source.UserId} t {target.UserId} - cached {fromCache} expires in {fromCache?.ExpiresAt} Ä"); if (this.HasCacheExpired(fromCache)) { - OwnUserRelations refreshed = this.CreateUserRelations(source, target, database); + OwnUserRelations refreshed = new() + { + IsHearted = database.IsUserFavouritedByUser(target, source), + }; this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnUserRelations s {source.UserId} t {target.UserId} - gotten from DB {refreshed} Ä"); this.CacheOwnUserRelations(source, target, refreshed); - return refreshed; + return new(refreshed, true); } - return fromCache!.Content; + return new(fromCache!.Content, false); } public void UpdateUserHeartedStatusByUser(GameUser source, GameUser target, bool newValue, GameDatabaseContext database) { - OwnUserRelations fromCache = this.GetOwnUserRelations(source, target, database); - fromCache.IsHearted = newValue; - this.CacheOwnUserRelations(source, target, fromCache); + CachedReturn fromCache = this.GetOwnUserRelations(source, target, database); + if (fromCache.WasRefreshed) return; // value is already up-to-date + + fromCache.Content.IsHearted = newValue; + this.CacheOwnUserRelations(source, target, fromCache.Content); } #endregion #region Own Level Relations - private OwnLevelRelations CreateLevelRelations(GameUser source, GameLevel target, GameDatabaseContext database) - { - return new() - { - IsHearted = database.IsLevelFavouritedByUser(target, source), - IsQueued = database.IsLevelQueuedByUser(target, source), - LevelRating = (int?)database.GetRatingByUser(target, source) ?? 0, - TotalPlayCount = database.GetTotalPlaysForLevelByUser(target, source), - TotalCompletionCount = database.GetTotalCompletionsForLevelByUser(target, source), - PhotoCount = database.GetTotalPhotosInLevelByUser(target, source), - }; - } - public void CacheOwnLevelRelations(GameUser source, GameLevel target, OwnLevelRelations newData) { DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); @@ -214,7 +199,15 @@ public CachedReturn GetOwnLevelRelations(GameUser source, Gam if (this.HasCacheExpired(fromCache)) { - OwnLevelRelations refreshed = this.CreateLevelRelations(source, target, database); + OwnLevelRelations refreshed = new() + { + IsHearted = database.IsLevelFavouritedByUser(target, source), + IsQueued = database.IsLevelQueuedByUser(target, source), + LevelRating = (int?)database.GetRatingByUser(target, source) ?? 0, + TotalPlayCount = database.GetTotalPlaysForLevelByUser(target, source), + TotalCompletionCount = database.GetTotalCompletionsForLevelByUser(target, source), + PhotoCount = database.GetTotalPhotosInLevelByUser(target, source), + }; this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnLevelRelations s {source.UserId} t {target.LevelId} - gotten from DB {refreshed} plays {refreshed.TotalPlayCount} Ä"); this.CacheOwnLevelRelations(source, target, refreshed); diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiGameLevelOwnRelationsResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiGameLevelOwnRelationsResponse.cs index a7a8014d6..6c526ed67 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiGameLevelOwnRelationsResponse.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiGameLevelOwnRelationsResponse.cs @@ -23,7 +23,7 @@ public class ApiGameLevelOwnRelationsResponse : IApiResponse if (dataContext.User == null) return null; - OwnLevelRelations relations = dataContext.Cache.GetOwnLevelRelations(dataContext.User, level, dataContext.Database); + OwnLevelRelations relations = dataContext.Cache.GetOwnLevelRelations(dataContext.User, level, dataContext.Database).Content; return new() { From ad4e10d46ce988436381c8c379337babb70ed5b7 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 11 Mar 2026 14:01:28 +0100 Subject: [PATCH 06/15] fix compilation --- .../DataTypes/Response/Users/ApiGameUserOwnRelationsResponse.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserOwnRelationsResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserOwnRelationsResponse.cs index 5950b26c9..e44cfd0c4 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserOwnRelationsResponse.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserOwnRelationsResponse.cs @@ -14,7 +14,7 @@ public class ApiGameUserOwnRelationsResponse : IApiResponse if (dataContext.User == null) return null; - OwnUserRelations relations = dataContext.Cache.GetOwnUserRelations(dataContext.User, user, dataContext.Database); + OwnUserRelations relations = dataContext.Cache.GetOwnUserRelations(dataContext.User, user, dataContext.Database).Content; return new() { From b4aa0c5d68aebf1eb1140c3de3415000535e8c8a Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 11 Mar 2026 14:07:47 +0100 Subject: [PATCH 07/15] Dont cache GUIDs/blank hashes as GameAssets --- Refresh.Core/Services/CacheService.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Refresh.Core/Services/CacheService.cs b/Refresh.Core/Services/CacheService.cs index 31071b9f3..ff44b25f7 100644 --- a/Refresh.Core/Services/CacheService.cs +++ b/Refresh.Core/Services/CacheService.cs @@ -34,6 +34,7 @@ public CacheService(Logger logger, TimeProviderService time) : base(logger) #region Assets public void CacheAsset(string hash, GameAsset? asset) { + if (hash.StartsWith('g') || hash.IsBlankHash()) return; DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(AssetCacheDurationSeconds); this._cachedAssetData[hash] = new(asset, expiresAt); this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheAsset {hash} - will expire in {expiresAt} Ä"); @@ -42,6 +43,8 @@ public void CacheAsset(string hash, GameAsset? asset) // currently not necessary to return these with CachedReturn... public GameAsset? GetAssetInfo(string hash, GameDatabaseContext database) { + if (hash.StartsWith('g') || hash.IsBlankHash()) return null; + CachedData? fromCache = this._cachedAssetData.GetValueOrDefault(hash); this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetAssetInfo {hash} - cached {fromCache} expires in {fromCache?.ExpiresAt} Ä"); From c5a46e8f1026cf462138e88e6860133eae9741ea Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 11 Mar 2026 15:11:41 +0100 Subject: [PATCH 08/15] Various CacheService things --- Refresh.Core/Services/CacheService.cs | 77 +++++++++++++++------------ 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/Refresh.Core/Services/CacheService.cs b/Refresh.Core/Services/CacheService.cs index ff44b25f7..7b0f754c9 100644 --- a/Refresh.Core/Services/CacheService.cs +++ b/Refresh.Core/Services/CacheService.cs @@ -1,4 +1,3 @@ -using Bunkum.Core; using Bunkum.Core.Services; using MongoDB.Bson; using NotEnoughLogs; @@ -35,23 +34,20 @@ public CacheService(Logger logger, TimeProviderService time) : base(logger) public void CacheAsset(string hash, GameAsset? asset) { if (hash.StartsWith('g') || hash.IsBlankHash()) return; + DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(AssetCacheDurationSeconds); this._cachedAssetData[hash] = new(asset, expiresAt); - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheAsset {hash} - will expire in {expiresAt} Ä"); } // currently not necessary to return these with CachedReturn... public GameAsset? GetAssetInfo(string hash, GameDatabaseContext database) { if (hash.StartsWith('g') || hash.IsBlankHash()) return null; - CachedData? fromCache = this._cachedAssetData.GetValueOrDefault(hash); - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetAssetInfo {hash} - cached {fromCache} expires in {fromCache?.ExpiresAt} Ä"); if (this.HasCacheExpired(fromCache)) { GameAsset? refreshed = database.GetAssetFromHash(hash); - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetAssetInfo {hash} - gotten from DB {refreshed} Ä"); // Cache anyway, even if asset was not found in DB, for the same reason we're caching everything else // If the asset ever happens to be added to DB, it will be added to cache aswell by whatever is handling the upload/import anyway @@ -76,7 +72,6 @@ public void CacheSkillRewards(GameLevel level, List rewards) { DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); this._cachedSkillRewards[level.LevelId] = new(rewards, expiresAt); - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheSkillRewards {level.LevelId} - will expire in {expiresAt} Ä"); } public void RemoveSkillRewards(GameLevel level) @@ -87,12 +82,10 @@ public void RemoveSkillRewards(GameLevel level) public List GetSkillRewards(GameLevel level, GameDatabaseContext database) { CachedData>? fromCache = this._cachedSkillRewards.GetValueOrDefault(level.LevelId); - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetSkillRewards {level.LevelId} - cached {fromCache} expires in {fromCache?.ExpiresAt} Ä"); if (this.HasCacheExpired(fromCache)) { List refreshed = database.GetSkillRewardsForLevel(level).ToList(); - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetSkillRewards {level.LevelId} - gotten from DB {refreshed} Ä"); this.CacheSkillRewards(level, refreshed); return refreshed; @@ -108,7 +101,6 @@ public void CacheTags(GameLevel level, List tags) { DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); this._cachedLevelTags[level.LevelId] = new(tags, expiresAt); - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheTags {level.LevelId} - will expire in {expiresAt} Ä"); } // Make service re-get tags the next time they're requested, incase a tag is added or removed. @@ -121,12 +113,10 @@ public void RemoveTags(GameLevel level) public List GetTags(GameLevel level, GameDatabaseContext database) { CachedData>? fromCache = this._cachedLevelTags.GetValueOrDefault(level.LevelId); - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetTags {level.LevelId} - cached {fromCache} expires in {fromCache?.ExpiresAt} Ä"); if (this.HasCacheExpired(fromCache)) { List refreshed = database.GetTagsForLevel(level).ToList(); - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetTags {level.LevelId} - gotten from DB {refreshed} Ä"); this.CacheTags(level, refreshed); return refreshed; @@ -142,8 +132,10 @@ public List GetTags(GameLevel level, GameDatabaseContext database) public void CacheOwnUserRelations(GameUser source, GameUser target, OwnUserRelations newData) { DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); + if (!this._cachedOwnUserRelations.ContainsKey(source.UserId)) + this._cachedOwnUserRelations[source.UserId] = []; + this._cachedOwnUserRelations[source.UserId][target.UserId] = new(newData, expiresAt); - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheOwnUserRelations s {source.UserId} t {target.UserId} - will expire in {expiresAt} Ä"); } public void RemoveOwnUserRelations(GameUser source, GameUser target) @@ -154,7 +146,6 @@ public void RemoveOwnUserRelations(GameUser source, GameUser target) public CachedReturn GetOwnUserRelations(GameUser source, GameUser target, GameDatabaseContext database) { CachedData? fromCache = this._cachedOwnUserRelations.GetValueOrDefault(source.UserId)?.GetValueOrDefault(target.UserId); - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnUserRelations s {source.UserId} t {target.UserId} - cached {fromCache} expires in {fromCache?.ExpiresAt} Ä"); if (this.HasCacheExpired(fromCache)) { @@ -162,7 +153,6 @@ public CachedReturn GetOwnUserRelations(GameUser source, GameU { IsHearted = database.IsUserFavouritedByUser(target, source), }; - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnUserRelations s {source.UserId} t {target.UserId} - gotten from DB {refreshed} Ä"); this.CacheOwnUserRelations(source, target, refreshed); return new(refreshed, true); @@ -176,8 +166,8 @@ public void UpdateUserHeartedStatusByUser(GameUser source, GameUser target, bool CachedReturn fromCache = this.GetOwnUserRelations(source, target, database); if (fromCache.WasRefreshed) return; // value is already up-to-date - fromCache.Content.IsHearted = newValue; - this.CacheOwnUserRelations(source, target, fromCache.Content); + // dictionaries are already ensured to exist + this._cachedOwnUserRelations[source.UserId][target.UserId].Content.IsHearted = newValue; } #endregion @@ -186,8 +176,10 @@ public void UpdateUserHeartedStatusByUser(GameUser source, GameUser target, bool public void CacheOwnLevelRelations(GameUser source, GameLevel target, OwnLevelRelations newData) { DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); + if (!this._cachedOwnLevelRelations.ContainsKey(source.UserId)) + this._cachedOwnLevelRelations[source.UserId] = []; + this._cachedOwnLevelRelations[source.UserId][target.LevelId] = new(newData, expiresAt); - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"CacheOwnLevelRelations s {source.UserId} t {target.LevelId} - will expire in {expiresAt} Ä"); } public void RemoveOwnLevelRelations(GameUser source, GameLevel target) @@ -198,7 +190,6 @@ public void RemoveOwnLevelRelations(GameUser source, GameLevel target) public CachedReturn GetOwnLevelRelations(GameUser source, GameLevel target, GameDatabaseContext database) { CachedData? fromCache = this._cachedOwnLevelRelations.GetValueOrDefault(source.UserId)?.GetValueOrDefault(target.LevelId); - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnLevelRelations s {source.UserId} t {target.LevelId} - cached {fromCache} expires in {fromCache} Ä"); if (this.HasCacheExpired(fromCache)) { @@ -211,7 +202,6 @@ public CachedReturn GetOwnLevelRelations(GameUser source, Gam TotalCompletionCount = database.GetTotalCompletionsForLevelByUser(target, source), PhotoCount = database.GetTotalPhotosInLevelByUser(target, source), }; - this.Logger.LogDebug(BunkumCategory.UserPhotos, $"GetOwnLevelRelations s {source.UserId} t {target.LevelId} - gotten from DB {refreshed} plays {refreshed.TotalPlayCount} Ä"); this.CacheOwnLevelRelations(source, target, refreshed); return new(refreshed, true); @@ -225,8 +215,8 @@ public void UpdateLevelHeartedStatusByUser(GameUser source, GameLevel target, bo CachedReturn fromCache = this.GetOwnLevelRelations(source, target, database); if (fromCache.WasRefreshed) return; // value is already up-to-date - fromCache.Content.IsHearted = newValue; - this.CacheOwnLevelRelations(source, target, fromCache.Content); + // dictionaries are already ensured to exist + this._cachedOwnLevelRelations[source.UserId][target.LevelId].Content.IsHearted = newValue; } public void UpdateLevelQueuedStatusByUser(GameUser source, GameLevel target, bool newValue, GameDatabaseContext database) @@ -234,11 +224,11 @@ public void UpdateLevelQueuedStatusByUser(GameUser source, GameLevel target, boo CachedReturn fromCache = this.GetOwnLevelRelations(source, target, database); if (fromCache.WasRefreshed) return; // value is already up-to-date - fromCache.Content.IsQueued = newValue; - this.CacheOwnLevelRelations(source, target, fromCache.Content); + // dictionaries are already ensured to exist + this._cachedOwnLevelRelations[source.UserId][target.LevelId].Content.IsQueued = newValue; } - public void DequeueAllLevelsByUser(GameUser source) + public void ClearQueueByUser(GameUser source) { foreach (KeyValuePair> relations in this._cachedOwnLevelRelations.GetValueOrDefault(source.UserId)?.ToArray() ?? []) { @@ -246,7 +236,6 @@ public void DequeueAllLevelsByUser(GameUser source) if (this.HasCacheExpired(relations.Value)) continue; relations.Value.Content.IsQueued = false; - this.Logger.LogDebug(BunkumCategory.UserLevels, $"DequeueAllLevels level {relations.Key} was queued by {source}"); this._cachedOwnLevelRelations[source.UserId][relations.Key] = relations.Value; } } @@ -256,8 +245,8 @@ public void UpdateLevelRatingByUser(GameUser source, GameLevel target, int newRa CachedReturn fromCache = this.GetOwnLevelRelations(source, target, database); if (fromCache.WasRefreshed) return; // value is already up-to-date - fromCache.Content.LevelRating = newRating; - this.CacheOwnLevelRelations(source, target, fromCache.Content); + // dictionaries are already ensured to exist + this._cachedOwnLevelRelations[source.UserId][target.LevelId].Content.LevelRating = newRating; } public void IncrementLevelTotalPlaysByUser(GameUser source, GameLevel target, int incrementor, GameDatabaseContext database) @@ -265,8 +254,8 @@ public void IncrementLevelTotalPlaysByUser(GameUser source, GameLevel target, in CachedReturn fromCache = this.GetOwnLevelRelations(source, target, database); if (fromCache.WasRefreshed) return; // value is already up-to-date - fromCache.Content.TotalPlayCount += incrementor; - this.CacheOwnLevelRelations(source, target, fromCache.Content); + // dictionaries are already ensured to exist + this._cachedOwnLevelRelations[source.UserId][target.LevelId].Content.TotalPlayCount += incrementor; } public void IncrementLevelTotalCompletionsByUser(GameUser source, GameLevel target, int incrementor, GameDatabaseContext database) @@ -274,8 +263,19 @@ public void IncrementLevelTotalCompletionsByUser(GameUser source, GameLevel targ CachedReturn fromCache = this.GetOwnLevelRelations(source, target, database); if (fromCache.WasRefreshed) return; // value is already up-to-date - fromCache.Content.TotalCompletionCount += incrementor; - this.CacheOwnLevelRelations(source, target, fromCache.Content); + // dictionaries are already ensured to exist + this._cachedOwnLevelRelations[source.UserId][target.LevelId].Content.TotalCompletionCount += incrementor; + } + + public void ResetLevelCompletionCountByUser(GameUser source) + { + foreach (KeyValuePair> relations in this._cachedOwnLevelRelations.GetValueOrDefault(source.UserId)?.ToArray() ?? []) + { + if (this.HasCacheExpired(relations.Value)) continue; + + relations.Value.Content.TotalCompletionCount = 0; + this._cachedOwnLevelRelations[source.UserId][relations.Key] = relations.Value; + } } public void IncrementLevelPhotosByUser(GameUser source, GameLevel target, int incrementor, GameDatabaseContext database) @@ -283,8 +283,19 @@ public void IncrementLevelPhotosByUser(GameUser source, GameLevel target, int in CachedReturn fromCache = this.GetOwnLevelRelations(source, target, database); if (fromCache.WasRefreshed) return; // value is already up-to-date - fromCache.Content.PhotoCount += incrementor; - this.CacheOwnLevelRelations(source, target, fromCache.Content); + // dictionaries are already ensured to exist + this._cachedOwnLevelRelations[source.UserId][target.LevelId].Content.PhotoCount += incrementor; + } + + public void ResetLevelPhotoCountsByUser(GameUser source) + { + foreach (KeyValuePair> relations in this._cachedOwnLevelRelations.GetValueOrDefault(source.UserId)?.ToArray() ?? []) + { + if (this.HasCacheExpired(relations.Value)) continue; + + relations.Value.Content.PhotoCount = 0; + this._cachedOwnLevelRelations[source.UserId][relations.Key] = relations.Value; + } } #endregion From 58c265f96a7166c7ba8ee21a21063724c8096377 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 11 Mar 2026 15:13:16 +0100 Subject: [PATCH 09/15] Use CacheService in more places --- Refresh.Core/Extensions/PhotoExtensions.cs | 6 +++--- .../Endpoints/Admin/AdminLeaderboardApiEndpoints.cs | 5 +++-- .../Endpoints/Admin/AdminPhotoApiEndpoints.cs | 9 +++++++-- .../Endpoints/LevelApiEndpoints.cs | 2 +- .../Endpoints/PhotoApiEndpoints.cs | 4 +++- .../Endpoints/ResourceApiEndpoints.cs | 2 +- .../Extensions/StringExtensions.cs | 2 +- .../Endpoints/DataTypes/Response/GameLevelResponse.cs | 2 +- .../Endpoints/Levels/LeaderboardEndpoints.cs | 1 + .../Endpoints/Levels/PublishEndpoints.cs | 2 +- Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs | 10 ++++++++-- Refresh.Interfaces.Game/Endpoints/RelationEndpoints.cs | 2 +- Refresh.Interfaces.Game/Endpoints/ResourceEndpoints.cs | 2 +- 13 files changed, 32 insertions(+), 17 deletions(-) diff --git a/Refresh.Core/Extensions/PhotoExtensions.cs b/Refresh.Core/Extensions/PhotoExtensions.cs index a5aa6d72a..7713d78c0 100644 --- a/Refresh.Core/Extensions/PhotoExtensions.cs +++ b/Refresh.Core/Extensions/PhotoExtensions.cs @@ -15,9 +15,9 @@ public static SerializedPhoto FromGamePhoto(GamePhoto photo, DataContext dataCon // NOTE: we usually would do `if psp, prepend psp/ to the hashes`, // but since we are converting the psp TGA assets to PNG in FillInExtraData, we don't need to! // also, I think the game would get mad if we did that - LargeHash = dataContext.Database.GetAssetFromHash(photo.LargeAsset.AssetHash)?.GetAsPhoto(dataContext.Game, dataContext) ?? photo.LargeAsset.AssetHash, - MediumHash = dataContext.Database.GetAssetFromHash(photo.MediumAsset.AssetHash)?.GetAsPhoto(dataContext.Game, dataContext) ?? photo.MediumAsset.AssetHash, - SmallHash = dataContext.Database.GetAssetFromHash(photo.SmallAsset.AssetHash)?.GetAsPhoto(dataContext.Game, dataContext) ?? photo.SmallAsset.AssetHash, + LargeHash = photo.LargeAsset.GetAsPhoto(dataContext.Game, dataContext) ?? photo.LargeAsset.AssetHash, + MediumHash = photo.MediumAsset.GetAsPhoto(dataContext.Game, dataContext) ?? photo.MediumAsset.AssetHash, + SmallHash = photo.SmallAsset.GetAsPhoto(dataContext.Game, dataContext) ?? photo.SmallAsset.AssetHash, PlanHash = photo.PlanHash, PhotoSubjects = [], }; diff --git a/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminLeaderboardApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminLeaderboardApiEndpoints.cs index 2b8059256..3e1f0fac8 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminLeaderboardApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminLeaderboardApiEndpoints.cs @@ -3,6 +3,7 @@ using Bunkum.Core.Endpoints; using Bunkum.Protocols.Http; using Refresh.Core.Authentication.Permission; +using Refresh.Core.Types.Data; using Refresh.Database; using Refresh.Database.Models.Levels.Scores; using Refresh.Database.Models.Users; @@ -17,7 +18,7 @@ public class AdminLeaderboardApiEndpoints : EndpointGroup [ApiV3Endpoint("admin/scores/{uuid}", HttpMethods.Delete), MinimumRole(GameUserRole.Moderator)] [DocSummary("Removes a score by the score's UUID.")] [DocError(typeof(ApiNotFoundError), ApiNotFoundError.ScoreMissingErrorWhen)] - public ApiOkResponse DeleteScore(RequestContext context, GameDatabaseContext database, + public ApiOkResponse DeleteScore(RequestContext context, GameDatabaseContext database, DataContext dataContext, [DocSummary("The UUID of the score")] string uuid) { GameScore? score = database.GetScoreByUuid(uuid); @@ -31,7 +32,7 @@ public ApiOkResponse DeleteScore(RequestContext context, GameDatabaseContext dat [ApiV3Endpoint("admin/users/{idType}/{id}/scores", HttpMethods.Delete), MinimumRole(GameUserRole.Moderator)] [DocSummary("Deletes all scores set by a user, specified by UUID or username.")] [DocError(typeof(ApiNotFoundError), ApiNotFoundError.UserMissingErrorWhen)] - public ApiOkResponse DeleteScoresSetByUser(RequestContext context, GameDatabaseContext database, + public ApiOkResponse DeleteScoresSetByUser(RequestContext context, GameDatabaseContext database, DataContext dataContext, [DocSummary(SharedParamDescriptions.UserIdParam)] string id, [DocSummary(SharedParamDescriptions.UserIdTypeParam)] string idType) { diff --git a/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminPhotoApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminPhotoApiEndpoints.cs index 8493d75ad..eb526b023 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminPhotoApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminPhotoApiEndpoints.cs @@ -3,6 +3,7 @@ using Bunkum.Core.Endpoints; using Bunkum.Protocols.Http; using Refresh.Core.Authentication.Permission; +using Refresh.Core.Types.Data; using Refresh.Database; using Refresh.Database.Models.Photos; using Refresh.Database.Models.Users; @@ -17,7 +18,7 @@ public class AdminPhotoApiEndpoints : EndpointGroup [ApiV3Endpoint("admin/users/{idType}/{id}/photos", HttpMethods.Delete), MinimumRole(GameUserRole.Moderator)] [DocSummary("Deletes all photos posted by a user. Gets user by their UUID or username.")] [DocError(typeof(ApiNotFoundError), ApiNotFoundError.UserMissingErrorWhen)] - public ApiOkResponse DeletePhotosPostedByUser(RequestContext context, GameDatabaseContext database, + public ApiOkResponse DeletePhotosPostedByUser(RequestContext context, GameDatabaseContext database, DataContext dataContext, [DocSummary(SharedParamDescriptions.UserIdParam)] string id, [DocSummary(SharedParamDescriptions.UserIdTypeParam)] string idType) { @@ -25,18 +26,22 @@ public ApiOkResponse DeletePhotosPostedByUser(RequestContext context, GameDataba if (user == null) return ApiNotFoundError.UserMissingError; database.DeletePhotosPostedByUser(user); + dataContext.Cache.ResetLevelPhotoCountsByUser(user); return new ApiOkResponse(); } [ApiV3Endpoint("admin/photos/id/{id}", HttpMethods.Delete), MinimumRole(GameUserRole.Moderator)] [DocSummary("Deletes a photo.")] [DocError(typeof(ApiNotFoundError), ApiNotFoundError.PhotoMissingErrorWhen)] - public ApiOkResponse DeletePhoto(RequestContext context, GameDatabaseContext database, int id) + public ApiOkResponse DeletePhoto(RequestContext context, GameDatabaseContext database, int id, DataContext dataContext) { GamePhoto? photo = database.GetPhotoById(id); if (photo == null) return ApiNotFoundError.PhotoMissingError; database.RemovePhoto(photo); + if (photo.Level != null) + dataContext.Cache.IncrementLevelPhotosByUser(photo.Publisher, photo.Level, -1, database); + return new ApiOkResponse(); } } \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs index f75c7badb..00cdd0564 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs @@ -235,7 +235,7 @@ public ApiOkResponse ClearQueuedLevels(RequestContext context, GameDatabaseConte IDataStore dataStore, GameUser user, DataContext dataContext) { database.ClearQueue(user); - dataContext.Cache.DequeueAllLevelsByUser(user); + dataContext.Cache.ClearQueueByUser(user); return new ApiOkResponse(); } } \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/PhotoApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/PhotoApiEndpoints.cs index 1b602b566..91602e70c 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/PhotoApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/PhotoApiEndpoints.cs @@ -25,7 +25,7 @@ public class PhotoApiEndpoints : EndpointGroup [DocSummary("Deletes an uploaded photo")] [DocError(typeof(ApiNotFoundError), ApiNotFoundError.PhotoMissingErrorWhen)] [DocError(typeof(ApiValidationError), ApiValidationError.NoPhotoDeletionPermissionErrorWhen)] - public ApiResponse DeletePhoto(RequestContext context, GameDatabaseContext database, GameUser user, int id) + public ApiResponse DeletePhoto(RequestContext context, GameDatabaseContext database, GameUser user, int id, DataContext dataContext) { GamePhoto? photo = database.GetPhotoById(id); if (photo == null) return ApiNotFoundError.PhotoMissingError; @@ -34,6 +34,8 @@ public ApiResponse DeletePhoto(RequestContext context, GameDat return ApiValidationError.NoPhotoDeletionPermissionError; database.RemovePhoto(photo); + if (photo.Level != null) + dataContext.Cache.IncrementLevelPhotosByUser(photo.Publisher, photo.Level, -1, database); return new ApiOkResponse(); } diff --git a/Refresh.Interfaces.APIv3/Endpoints/ResourceApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/ResourceApiEndpoints.cs index 0fe86e0d5..782c8cdc7 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/ResourceApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/ResourceApiEndpoints.cs @@ -182,7 +182,7 @@ IntegrationConfig integration if (dataStore.ExistsInStore(hash)) { - GameAsset? existingAsset = database.GetAssetFromHash(hash); + GameAsset? existingAsset = dataContext.Cache.GetAssetInfo(hash, database); if (existingAsset == null) return ApiInternalError.HashNotFoundInDatabaseError; diff --git a/Refresh.Interfaces.APIv3/Extensions/StringExtensions.cs b/Refresh.Interfaces.APIv3/Extensions/StringExtensions.cs index 202772847..56f74c45f 100644 --- a/Refresh.Interfaces.APIv3/Extensions/StringExtensions.cs +++ b/Refresh.Interfaces.APIv3/Extensions/StringExtensions.cs @@ -28,7 +28,7 @@ public static (string?, ApiError?) ValidateIcon(this string? iconReference, Data } else { - GameAsset? asset = dataContext.Database.GetAssetFromHash(iconReference); + GameAsset? asset = dataContext.Cache.GetAssetInfo(iconReference, dataContext.Database); if (asset == null) return (null, ApiValidationError.IconMissingError); diff --git a/Refresh.Interfaces.Game/Endpoints/DataTypes/Response/GameLevelResponse.cs b/Refresh.Interfaces.Game/Endpoints/DataTypes/Response/GameLevelResponse.cs index b6e498c5d..6e19b125d 100644 --- a/Refresh.Interfaces.Game/Endpoints/DataTypes/Response/GameLevelResponse.cs +++ b/Refresh.Interfaces.Game/Endpoints/DataTypes/Response/GameLevelResponse.cs @@ -144,7 +144,7 @@ private static GameLevelResponse FromMinimal(GameMinimalLevelResponse minimal) if (dataContext.Game is TokenGame.LittleBigPlanetVita or TokenGame.BetaBuild) { - GameAsset? rootResourceAsset = dataContext.Database.GetAssetFromHash(response.RootResource); + GameAsset? rootResourceAsset = dataContext.Cache.GetAssetInfo(response.RootResource, dataContext.Database); if (rootResourceAsset != null) { rootResourceAsset.TraverseDependenciesRecursively(dataContext.Database, (_, asset) => diff --git a/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs index b33cb3aea..40d8ed5a3 100644 --- a/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs @@ -185,6 +185,7 @@ public Response SubmitScore(RequestContext context, GameUser user, GameServerCon DatabaseList scores = database.GetRankedScoresAroundScore(score, 5); this.AwardScoreboardPins(scores, dataContext, user, level); + dataContext.Cache.IncrementLevelTotalCompletionsByUser(user, level, 1, database); return new Response(SerializedScoreLeaderboardList.FromDatabaseList(scores, dataContext), ContentType.Xml); } diff --git a/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs index 01e638989..2720fe612 100644 --- a/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Levels/PublishEndpoints.cs @@ -192,7 +192,7 @@ public Response PublishLevel(RequestContext context, //Make sure the root resource exists in the data store if (!dataContext.DataStore.ExistsInStore(rootResourcePath)) return NotFound; - GameAsset? asset = dataContext.Database.GetAssetFromHash(body.RootResource); + GameAsset? asset = dataContext.Cache.GetAssetInfo(body.RootResource, dataContext.Database); if (asset != null && dataContext.Game != TokenGame.LittleBigPlanetPSP) { // ReSharper disable once ConvertIfStatementToSwitchStatement diff --git a/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs index 658e15846..71e877b09 100644 --- a/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/PhotoEndpoints.cs @@ -55,7 +55,7 @@ public Response UploadPhoto(RequestContext context, SerializedPhoto body, GameDa List hashes = [body.LargeHash, body.MediumHash, body.SmallHash]; foreach (string hash in hashes.Distinct()) { - GameAsset? gameAsset = database.GetAssetFromHash(hash); + GameAsset? gameAsset = dataContext.Cache.GetAssetInfo(hash, database); if(gameAsset == null) continue; if (aipi != null && aipi.ScanAndHandleAsset(dataContext, gameAsset)) return Unauthorized; @@ -64,11 +64,14 @@ public Response UploadPhoto(RequestContext context, SerializedPhoto body, GameDa GameLevel? level = body.Level == null ? null : database.GetLevelByIdAndType(body.Level.Type, body.Level.LevelId); database.UploadPhoto(body, body.PhotoSubjects, user, level); + if (level != null) + dataContext.Cache.IncrementLevelPhotosByUser(user, level, 1, database); + return OK; } [GameEndpoint("deletePhoto/{id}", HttpMethods.Post)] - public Response DeletePhoto(RequestContext context, GameDatabaseContext database, GameUser user, int id) + public Response DeletePhoto(RequestContext context, GameDatabaseContext database, GameUser user, int id, DataContext dataContext) { GamePhoto? photo = database.GetPhotoById(id); if (photo == null) return NotFound; @@ -77,6 +80,9 @@ public Response DeletePhoto(RequestContext context, GameDatabaseContext database return Unauthorized; database.RemovePhoto(photo); + if (photo.Level != null) + dataContext.Cache.IncrementLevelPhotosByUser(photo.Publisher, photo.Level, -1, database); + return OK; } diff --git a/Refresh.Interfaces.Game/Endpoints/RelationEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/RelationEndpoints.cs index aba0c3f61..26c4c1ff7 100644 --- a/Refresh.Interfaces.Game/Endpoints/RelationEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/RelationEndpoints.cs @@ -151,7 +151,7 @@ public Response DequeueLevel(RequestContext context, GameDatabaseContext databas public Response ClearQueue(RequestContext context, GameDatabaseContext database, GameUser user, DataContext dataContext) { database.ClearQueue(user); - dataContext.Cache.DequeueAllLevelsByUser(user); + dataContext.Cache.ClearQueueByUser(user); return OK; } diff --git a/Refresh.Interfaces.Game/Endpoints/ResourceEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/ResourceEndpoints.cs index f54671c2e..4dc19a830 100644 --- a/Refresh.Interfaces.Game/Endpoints/ResourceEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/ResourceEndpoints.cs @@ -123,7 +123,7 @@ public Response GetResource(RequestContext context, GameUser user, Token token, // Part of a workaround to prevent LBP Hub from breaking challenge ghost replay. // See ChallengeGhostRateLimitService's summary for more information. - if (token.TokenGame == TokenGame.BetaBuild && dataContext.Database.GetAssetFromHash(hash)?.AssetType == GameAssetType.ChallengeGhost) + if (token.TokenGame == TokenGame.BetaBuild && dataContext.Cache.GetAssetInfo(hash, dataContext.Database)?.AssetType == GameAssetType.ChallengeGhost) { if (ghostService.IsUserRateLimited(user.UserId)) { From 4a8ace160b8a6a6f11ad6bfd880267818a34447e Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 11 Mar 2026 17:12:37 +0100 Subject: [PATCH 10/15] Include CacheService in TestRefreshGameServer --- RefreshTests.GameServer/GameServer/TestRefreshGameServer.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/RefreshTests.GameServer/GameServer/TestRefreshGameServer.cs b/RefreshTests.GameServer/GameServer/TestRefreshGameServer.cs index c87e65242..676a20101 100644 --- a/RefreshTests.GameServer/GameServer/TestRefreshGameServer.cs +++ b/RefreshTests.GameServer/GameServer/TestRefreshGameServer.cs @@ -74,6 +74,7 @@ protected override void SetupServices() this.Server.AddService(); this.Server.AddService(); this.Server.AddService(); + this.Server.AddService(); // Must always be last, see comment in RefreshGameServer this.Server.AddService(); From 0400c7f8514ef0c83fc0c9315f31483c662f0b69 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 11 Mar 2026 19:15:47 +0100 Subject: [PATCH 11/15] Dont clear queue if no levels are queued --- Refresh.Database/GameDatabaseContext.Relations.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Refresh.Database/GameDatabaseContext.Relations.cs b/Refresh.Database/GameDatabaseContext.Relations.cs index 6b958cbdc..000434a76 100644 --- a/Refresh.Database/GameDatabaseContext.Relations.cs +++ b/Refresh.Database/GameDatabaseContext.Relations.cs @@ -252,6 +252,8 @@ public bool DequeueLevel(GameLevel level, GameUser user) public void ClearQueue(GameUser user) { + if (this.GetTotalLevelsQueuedByUser(user) <= 0) return; + this.WriteEnsuringStatistics(user, () => { user.Statistics!.QueueCount = 0; From 8b5c1431c3921bd8188b5c8e370c310d8f13bc32 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 11 Mar 2026 19:18:50 +0100 Subject: [PATCH 12/15] Properly log CacheService happenings, automatically remove expired cache --- Refresh.Common/RefreshContext.cs | 1 + Refresh.Core/Services/CacheService.cs | 159 ++++++++++++++++++-- Refresh.Database/Models/Levels/GameLevel.cs | 6 + 3 files changed, 155 insertions(+), 11 deletions(-) diff --git a/Refresh.Common/RefreshContext.cs b/Refresh.Common/RefreshContext.cs index 033642bb6..540f15a21 100644 --- a/Refresh.Common/RefreshContext.cs +++ b/Refresh.Common/RefreshContext.cs @@ -12,4 +12,5 @@ public enum RefreshContext Aipi, Presence, Database, + CacheService, } \ No newline at end of file diff --git a/Refresh.Core/Services/CacheService.cs b/Refresh.Core/Services/CacheService.cs index 7b0f754c9..89529116c 100644 --- a/Refresh.Core/Services/CacheService.cs +++ b/Refresh.Core/Services/CacheService.cs @@ -1,6 +1,16 @@ +// Uncomment to enable extra logging. +// Do not enable in production as this will flood your console and might be memory-heavy. +//#define CACHE_DEBUG + +#if !DEBUG +#undef CACHE_DEBUG +#endif + +using System.Diagnostics; using Bunkum.Core.Services; using MongoDB.Bson; using NotEnoughLogs; +using Refresh.Common; using Refresh.Core.Types.Cache; using Refresh.Core.Types.Relations; using Refresh.Database; @@ -17,25 +27,120 @@ public class CacheService : EndpointService private readonly Dictionary>> _cachedSkillRewards = []; // level ID -> data private readonly Dictionary>> _cachedLevelTags = []; // level ID -> data - // TODO: maybe save these in DB aswell? private readonly Dictionary>> _cachedOwnUserRelations = []; // source user UUID -> target user UUID -> data private readonly Dictionary>> _cachedOwnLevelRelations = []; // source user UUID -> level ID -> data private const int LevelCacheDurationSeconds = 60 * 10; private const int AssetCacheDurationSeconds = 60 * 60; // GameAssets basically never change for now - // TODO: some way to auto-remove cached stuff if expired + // TODO: unit tests for this public CacheService(Logger logger, TimeProviderService time) : base(logger) { this._time = time; } + [Conditional("CACHE_DEBUG")] + private void Log(ReadOnlySpan format, params object[] args) + { + this.Logger.LogDebug(RefreshContext.CacheService, format, args); + } + + public override void Initialize() + { + base.Initialize(); + + // periodically automatically remove expired data if it hasn't been removed somehow else before, + // to not keep unused data for a potentially undefined time + Thread expirationThread = new(() => + { + while(true) + { + Thread.Sleep(1000 * 60 * 2); + this.RemoveExpiredCache(); + } + }); + expirationThread.Start(); + } + + private void RemoveExpiredCache() + { + this.Log("Automatically removing expired cache..."); + DateTimeOffset now = this._time.TimeProvider.Now; + + foreach(KeyValuePair> cache in this._cachedAssetData) + { + if (this.HasCacheExpired(cache.Value, now)) + { + this.Log("Automatically removing expired GameAsset taken by hash {0}", cache.Key); + this._cachedAssetData.Remove(cache.Key); + } + } + + foreach(KeyValuePair>> cache in this._cachedSkillRewards) + { + if (this.HasCacheExpired(cache.Value, now)) + { + this.Log("Automatically removing expired GameSkillReward list taken from level {0}", cache.Key); + this._cachedSkillRewards.Remove(cache.Key); + } + } + + foreach(KeyValuePair>> cache in this._cachedLevelTags) + { + if (this.HasCacheExpired(cache.Value, now)) + { + this.Log("Automatically removing expired Tag list taken from level {0}", cache.Key); + this._cachedLevelTags.Remove(cache.Key); + } + } + + foreach(KeyValuePair>> cache in this._cachedOwnUserRelations) + { + foreach(KeyValuePair> innerCache in cache.Value) + { + if (this.HasCacheExpired(innerCache.Value, now)) + { + this.Log("Automatically removing expired OwnUserRelations by user {0} for user {1}", cache.Key, innerCache.Key); + cache.Value.Remove(innerCache.Key); + } + } + + if (cache.Value.Count <= 0) + { + this.Log("Automatically removing empty OwnUserRelations dictionary by user {0}", cache.Key); + this._cachedOwnUserRelations.Remove(cache.Key); + } + } + + foreach(KeyValuePair>> cache in this._cachedOwnLevelRelations) + { + foreach(KeyValuePair> innerCache in cache.Value) + { + if (this.HasCacheExpired(innerCache.Value, now)) + { + this.Log("Automatically removing expired OwnLevelRelations by user {0} for level {1}", cache.Key, innerCache.Key); + cache.Value.Remove(innerCache.Key); + } + } + + if (cache.Value.Count <= 0) + { + this.Log("Automatically removing empty OwnLevelRelations dictionary by user {0}", cache.Key); + this._cachedOwnLevelRelations.Remove(cache.Key); + } + } + } + + private bool HasCacheExpired(CachedData? cached, DateTimeOffset? now = null) + => cached == null || cached.ExpiresAt < (now ?? this._time.TimeProvider.Now); + #region Assets public void CacheAsset(string hash, GameAsset? asset) { if (hash.StartsWith('g') || hash.IsBlankHash()) return; DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(AssetCacheDurationSeconds); + this.Log("Refreshing GameAsset {0} cache, it will expire at {1}", hash, expiresAt); this._cachedAssetData[hash] = new(asset, expiresAt); } @@ -43,10 +148,13 @@ public void CacheAsset(string hash, GameAsset? asset) public GameAsset? GetAssetInfo(string hash, GameDatabaseContext database) { if (hash.StartsWith('g') || hash.IsBlankHash()) return null; + + this.Log("Looking up GameAsset {0} in cache", hash); CachedData? fromCache = this._cachedAssetData.GetValueOrDefault(hash); if (this.HasCacheExpired(fromCache)) { + this.Log("Looking up GameAsset {0} in DB", hash); GameAsset? refreshed = database.GetAssetFromHash(hash); // Cache anyway, even if asset was not found in DB, for the same reason we're caching everything else @@ -55,7 +163,8 @@ public void CacheAsset(string hash, GameAsset? asset) return refreshed; } - return fromCache?.Content; + this.Log("Found unexpired GameAsset {0} in cache, it will expire at {1}", hash, fromCache!.ExpiresAt); + return fromCache!.Content; } #endregion @@ -71,26 +180,31 @@ public void RemoveLevelData(GameLevel level) public void CacheSkillRewards(GameLevel level, List rewards) { DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); + this.Log("Refreshing GameSkillRewards cache for level {0}, they will expire at {1}", level, expiresAt); this._cachedSkillRewards[level.LevelId] = new(rewards, expiresAt); } public void RemoveSkillRewards(GameLevel level) { + this.Log("Removing GameSkillRewards cache for level {0}", level); this._cachedSkillRewards.Remove(level.LevelId); } public List GetSkillRewards(GameLevel level, GameDatabaseContext database) { + this.Log("Looking up GameSkillRewards for level {0} in cache", level); CachedData>? fromCache = this._cachedSkillRewards.GetValueOrDefault(level.LevelId); if (this.HasCacheExpired(fromCache)) { + this.Log("Looking up GameSkillRewards for level {0} in DB", level); List refreshed = database.GetSkillRewardsForLevel(level).ToList(); this.CacheSkillRewards(level, refreshed); return refreshed; } + this.Log("Found unexpired GameSkillRewards for level {0} in cache, they will expire at {1}", level, fromCache!.ExpiresAt); return fromCache!.Content; } @@ -100,6 +214,7 @@ public List GetSkillRewards(GameLevel level, GameDatabaseContex public void CacheTags(GameLevel level, List tags) { DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); + this.Log("Refreshing Tags cache for level {0}, they will expire at {1}", level, expiresAt); this._cachedLevelTags[level.LevelId] = new(tags, expiresAt); } @@ -107,21 +222,25 @@ public void CacheTags(GameLevel level, List tags) // Can't just lazily add or remove tag to/from the cache because they're grouped and sorted by frequency (data we don't have here). public void RemoveTags(GameLevel level) { + this.Log("Removing Tags cache for level {0}", level); this._cachedLevelTags.Remove(level.LevelId); } public List GetTags(GameLevel level, GameDatabaseContext database) { + this.Log("Looking up Tags for level {0} in cache", level); CachedData>? fromCache = this._cachedLevelTags.GetValueOrDefault(level.LevelId); if (this.HasCacheExpired(fromCache)) { + this.Log("Looking up Tags for level {0} in DB", level); List refreshed = database.GetTagsForLevel(level).ToList(); this.CacheTags(level, refreshed); return refreshed; } + this.Log("Found unexpired GameSkillRewards for level {0} in cache, they will expire at {1}", level, fromCache!.ExpiresAt); return fromCache!.Content; } @@ -135,20 +254,24 @@ public void CacheOwnUserRelations(GameUser source, GameUser target, OwnUserRelat if (!this._cachedOwnUserRelations.ContainsKey(source.UserId)) this._cachedOwnUserRelations[source.UserId] = []; + this.Log("Refreshing OwnUserRelations cache by user {0} for user {1}, they will expire at {2}", source, target, expiresAt); this._cachedOwnUserRelations[source.UserId][target.UserId] = new(newData, expiresAt); } public void RemoveOwnUserRelations(GameUser source, GameUser target) { + this.Log("Resetting OwnUserRelations by user {0}", source); this._cachedOwnUserRelations.GetValueOrDefault(source.UserId)?.Remove(target.UserId); } public CachedReturn GetOwnUserRelations(GameUser source, GameUser target, GameDatabaseContext database) { + this.Log("Looking up OwnUserRelations by user {0} for user {0} in cache", source, target); CachedData? fromCache = this._cachedOwnUserRelations.GetValueOrDefault(source.UserId)?.GetValueOrDefault(target.UserId); if (this.HasCacheExpired(fromCache)) { + this.Log("Looking up OwnUserRelations by user {0} for user {1} in DB", source, target); OwnUserRelations refreshed = new() { IsHearted = database.IsUserFavouritedByUser(target, source), @@ -158,6 +281,7 @@ public CachedReturn GetOwnUserRelations(GameUser source, GameU return new(refreshed, true); } + this.Log("Found unexpired OwnUserRelations by user {0} for user {1} in cache, they will expire at {2}", source, target, fromCache!.ExpiresAt); return new(fromCache!.Content, false); } @@ -167,6 +291,7 @@ public void UpdateUserHeartedStatusByUser(GameUser source, GameUser target, bool if (fromCache.WasRefreshed) return; // value is already up-to-date // dictionaries are already ensured to exist + this.Log("Lazily setting hearted status by user {0} for user {1} to {2}", source, target, newValue); this._cachedOwnUserRelations[source.UserId][target.UserId].Content.IsHearted = newValue; } @@ -176,23 +301,22 @@ public void UpdateUserHeartedStatusByUser(GameUser source, GameUser target, bool public void CacheOwnLevelRelations(GameUser source, GameLevel target, OwnLevelRelations newData) { DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); + this.Log("Refreshing OwnUserRelations cache by user {0} for level {1}, they will expire at {2}", source, target, expiresAt); + if (!this._cachedOwnLevelRelations.ContainsKey(source.UserId)) this._cachedOwnLevelRelations[source.UserId] = []; this._cachedOwnLevelRelations[source.UserId][target.LevelId] = new(newData, expiresAt); } - public void RemoveOwnLevelRelations(GameUser source, GameLevel target) - { - this._cachedOwnLevelRelations.GetValueOrDefault(source.UserId)?.Remove(target.LevelId); - } - public CachedReturn GetOwnLevelRelations(GameUser source, GameLevel target, GameDatabaseContext database) { + this.Log("Looking up OwnLevelRelations by user {0} for level {0} in cache", source, target); CachedData? fromCache = this._cachedOwnLevelRelations.GetValueOrDefault(source.UserId)?.GetValueOrDefault(target.LevelId); if (this.HasCacheExpired(fromCache)) { + this.Log("Looking up OwnUserRelations by user {0} for level {1} in DB", source, target); OwnLevelRelations refreshed = new() { IsHearted = database.IsLevelFavouritedByUser(target, source), @@ -207,6 +331,7 @@ public CachedReturn GetOwnLevelRelations(GameUser source, Gam return new(refreshed, true); } + this.Log("Found unexpired OwnLevelRelations by user {0} for level {1} in cache, they will expire at {2}", source, target, fromCache!.ExpiresAt); return new(fromCache!.Content, false); } @@ -216,6 +341,7 @@ public void UpdateLevelHeartedStatusByUser(GameUser source, GameLevel target, bo if (fromCache.WasRefreshed) return; // value is already up-to-date // dictionaries are already ensured to exist + this.Log("Lazily setting hearted status by user {0} for level {1} to {2}", source, target, newValue); this._cachedOwnLevelRelations[source.UserId][target.LevelId].Content.IsHearted = newValue; } @@ -225,16 +351,20 @@ public void UpdateLevelQueuedStatusByUser(GameUser source, GameLevel target, boo if (fromCache.WasRefreshed) return; // value is already up-to-date // dictionaries are already ensured to exist + this.Log("Lazily setting queued status by user {0} for level {1} to {2}", source, target, newValue); this._cachedOwnLevelRelations[source.UserId][target.LevelId].Content.IsQueued = newValue; } public void ClearQueueByUser(GameUser source) { + this.Log("Resetting queued stati by user {0}", source); + foreach (KeyValuePair> relations in this._cachedOwnLevelRelations.GetValueOrDefault(source.UserId)?.ToArray() ?? []) { // will be refreshed later anyway; don't want too many DB calls here (e.g. user had 100 levels queued, this method would in that case send 600 DB queries) if (this.HasCacheExpired(relations.Value)) continue; + this.Log("Resetting queued status by user {0} for level {1}", source, relations.Key); relations.Value.Content.IsQueued = false; this._cachedOwnLevelRelations[source.UserId][relations.Key] = relations.Value; } @@ -246,6 +376,7 @@ public void UpdateLevelRatingByUser(GameUser source, GameLevel target, int newRa if (fromCache.WasRefreshed) return; // value is already up-to-date // dictionaries are already ensured to exist + this.Log("Lazily setting level rating by user {0} for level {1} to {2}", source, target, newRating); this._cachedOwnLevelRelations[source.UserId][target.LevelId].Content.LevelRating = newRating; } @@ -255,6 +386,7 @@ public void IncrementLevelTotalPlaysByUser(GameUser source, GameLevel target, in if (fromCache.WasRefreshed) return; // value is already up-to-date // dictionaries are already ensured to exist + this.Log("Lazily incrementing total plays by user {0} for level {1} to {2}", source, target, incrementor); this._cachedOwnLevelRelations[source.UserId][target.LevelId].Content.TotalPlayCount += incrementor; } @@ -264,15 +396,19 @@ public void IncrementLevelTotalCompletionsByUser(GameUser source, GameLevel targ if (fromCache.WasRefreshed) return; // value is already up-to-date // dictionaries are already ensured to exist + this.Log("Lazily incrementing total completions by user {0} for level {1} to {2}", source, target, incrementor); this._cachedOwnLevelRelations[source.UserId][target.LevelId].Content.TotalCompletionCount += incrementor; } public void ResetLevelCompletionCountByUser(GameUser source) { + this.Log("Resetting total completions by user {0}", source); + foreach (KeyValuePair> relations in this._cachedOwnLevelRelations.GetValueOrDefault(source.UserId)?.ToArray() ?? []) { if (this.HasCacheExpired(relations.Value)) continue; + this.Log("Resetting total completions by user {0} for level {1}", source, relations.Key); relations.Value.Content.TotalCompletionCount = 0; this._cachedOwnLevelRelations[source.UserId][relations.Key] = relations.Value; } @@ -284,22 +420,23 @@ public void IncrementLevelPhotosByUser(GameUser source, GameLevel target, int in if (fromCache.WasRefreshed) return; // value is already up-to-date // dictionaries are already ensured to exist + this.Log("Lazily incrementing total photos by user {0} for level {1} to {2}", source, target, incrementor); this._cachedOwnLevelRelations[source.UserId][target.LevelId].Content.PhotoCount += incrementor; } public void ResetLevelPhotoCountsByUser(GameUser source) { + this.Log("Resetting total photos by user {0}", source); + foreach (KeyValuePair> relations in this._cachedOwnLevelRelations.GetValueOrDefault(source.UserId)?.ToArray() ?? []) { if (this.HasCacheExpired(relations.Value)) continue; + this.Log("Resetting total photos by user {0} for level {1}", source, relations.Key); relations.Value.Content.PhotoCount = 0; this._cachedOwnLevelRelations[source.UserId][relations.Key] = relations.Value; } } #endregion - - private bool HasCacheExpired(CachedData? cached) - => cached == null || cached.ExpiresAt < this._time.TimeProvider.Now; } \ No newline at end of file diff --git a/Refresh.Database/Models/Levels/GameLevel.cs b/Refresh.Database/Models/Levels/GameLevel.cs index 173b7bab3..aa3317989 100644 --- a/Refresh.Database/Models/Levels/GameLevel.cs +++ b/Refresh.Database/Models/Levels/GameLevel.cs @@ -129,4 +129,10 @@ public GameLevel Clone() { return (GameLevel)this.MemberwiseClone(); } + + public override string ToString() + { + string shortenedTitle = this.Title.Length > 20 ? string.Concat(this.Title.AsSpan(0, 20), "-") : this.Title; + return $"'{shortenedTitle}' ({this.GameVersion} / {this.LevelId})"; + } } \ No newline at end of file From 399746f99aeed155494754621657c80482cdad21 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 11 Mar 2026 19:35:13 +0100 Subject: [PATCH 13/15] Lazily update more cached stats --- .../Endpoints/Admin/AdminLeaderboardApiEndpoints.cs | 2 ++ Refresh.Interfaces.APIv3/Endpoints/ReviewApiEndpoints.cs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminLeaderboardApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminLeaderboardApiEndpoints.cs index 3e1f0fac8..183ff2352 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminLeaderboardApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminLeaderboardApiEndpoints.cs @@ -25,6 +25,7 @@ public ApiOkResponse DeleteScore(RequestContext context, GameDatabaseContext dat if (score == null) return ApiNotFoundError.Instance; database.DeleteScore(score); + dataContext.Cache.IncrementLevelTotalCompletionsByUser(score.Publisher, score.Level, -1, database); return new ApiOkResponse(); } @@ -40,6 +41,7 @@ public ApiOkResponse DeleteScoresSetByUser(RequestContext context, GameDatabaseC if (user == null) return ApiNotFoundError.UserMissingError; database.DeleteScoresSetByUser(user); + dataContext.Cache.ResetLevelCompletionCountByUser(user); return new ApiOkResponse(); } } \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/ReviewApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/ReviewApiEndpoints.cs index a04c89156..d26363d84 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/ReviewApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/ReviewApiEndpoints.cs @@ -126,6 +126,7 @@ public ApiResponse PostReviewToLevel(RequestContext conte return ApiValidationError.RatingParseError; database.RateLevel(level, user, body.LevelRating.Value); + dataContext.Cache.UpdateLevelRatingByUser(user, level, (int)body.LevelRating.Value, database); } if (body.Content != null && body.Content.Length > UgcLimits.CommentLimit) @@ -167,6 +168,7 @@ public ApiResponse UpdateReviewById(RequestContext contex return ApiValidationError.RatingParseError; database.RateLevel(review.Level, user, body.LevelRating.Value); + dataContext.Cache.UpdateLevelRatingByUser(user, review.Level, (int)body.LevelRating.Value, database); } if (body.Content != null && body.Content.Length > UgcLimits.CommentLimit) From f2bf1d849fa143719958391e38ebe021aae97b66 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 11 Mar 2026 19:35:50 +0100 Subject: [PATCH 14/15] epic copy paste fail fix --- Refresh.Core/Services/CacheService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Refresh.Core/Services/CacheService.cs b/Refresh.Core/Services/CacheService.cs index 89529116c..bee8a0e00 100644 --- a/Refresh.Core/Services/CacheService.cs +++ b/Refresh.Core/Services/CacheService.cs @@ -301,7 +301,7 @@ public void UpdateUserHeartedStatusByUser(GameUser source, GameUser target, bool public void CacheOwnLevelRelations(GameUser source, GameLevel target, OwnLevelRelations newData) { DateTimeOffset expiresAt = this._time.TimeProvider.Now.AddSeconds(LevelCacheDurationSeconds); - this.Log("Refreshing OwnUserRelations cache by user {0} for level {1}, they will expire at {2}", source, target, expiresAt); + this.Log("Refreshing OwnLevelRelations cache by user {0} for level {1}, they will expire at {2}", source, target, expiresAt); if (!this._cachedOwnLevelRelations.ContainsKey(source.UserId)) this._cachedOwnLevelRelations[source.UserId] = []; @@ -316,7 +316,7 @@ public CachedReturn GetOwnLevelRelations(GameUser source, Gam if (this.HasCacheExpired(fromCache)) { - this.Log("Looking up OwnUserRelations by user {0} for level {1} in DB", source, target); + this.Log("Looking up OwnLevelRelations by user {0} for level {1} in DB", source, target); OwnLevelRelations refreshed = new() { IsHearted = database.IsLevelFavouritedByUser(target, source), From 0589c628ee4f61fddbf423a378ffdfd6b762f877 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 11 Mar 2026 20:51:41 +0100 Subject: [PATCH 15/15] More cases i missed --- .../Endpoints/Levels/LeaderboardEndpoints.cs | 3 ++- .../Endpoints/MatchingEndpoints.cs | 1 + Refresh.Interfaces.Game/Endpoints/ReviewEndpoints.cs | 12 +++++++++--- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs index 40d8ed5a3..d74dfa661 100644 --- a/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/Levels/LeaderboardEndpoints.cs @@ -34,7 +34,7 @@ public class LeaderboardEndpoints : EndpointGroup [MinimumRole(GameUserRole.Restricted)] [RateLimitSettings(PlayLevelEndpointLimits.TimeoutDuration, PlayLevelEndpointLimits.RequestAmount, PlayLevelEndpointLimits.BlockDuration, PlayLevelEndpointLimits.RequestBucket)] - public Response PlayLevel(RequestContext context, GameUser user, GameDatabaseContext database, string slotType, int id) + public Response PlayLevel(RequestContext context, GameUser user, GameDatabaseContext database, string slotType, int id, DataContext dataContext) { GameLevel? level = database.GetLevelByIdAndType(slotType, id); if (level == null) return NotFound; @@ -61,6 +61,7 @@ public Response PlayLevel(RequestContext context, GameUser user, GameDatabaseCon } database.PlayLevel(level, user, count); + dataContext.Cache.IncrementLevelTotalPlaysByUser(user, level, count, database); return OK; } diff --git a/Refresh.Interfaces.Game/Endpoints/MatchingEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/MatchingEndpoints.cs index 66715c02d..43ccc56fe 100644 --- a/Refresh.Interfaces.Game/Endpoints/MatchingEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/MatchingEndpoints.cs @@ -134,6 +134,7 @@ public Response EnterLevel(RequestContext context, Token token, MatchService mat if (level != null && roomSlotType == RoomSlotType.Online && room.LevelId != id && room.LevelType != roomSlotType) { dataContext.Database.PlayLevel(level, token.User, 1); + dataContext.Cache.IncrementLevelTotalPlaysByUser(token.User, level, 1, dataContext.Database); } room.LevelType = roomSlotType; diff --git a/Refresh.Interfaces.Game/Endpoints/ReviewEndpoints.cs b/Refresh.Interfaces.Game/Endpoints/ReviewEndpoints.cs index c081605f4..afbad46fd 100644 --- a/Refresh.Interfaces.Game/Endpoints/ReviewEndpoints.cs +++ b/Refresh.Interfaces.Game/Endpoints/ReviewEndpoints.cs @@ -27,7 +27,7 @@ public class ReviewEndpoints : EndpointGroup [RateLimitSettings(CommonRelationEndpointLimits.TimeoutDuration, CommonRelationEndpointLimits.RequestAmount, CommonRelationEndpointLimits.BlockDuration, CommonRelationEndpointLimits.RequestBucket)] public Response SubmitRating(RequestContext context, GameDatabaseContext database, GameUser user, string slotType, - int id, GameServerConfig config) + int id, GameServerConfig config, DataContext dataContext) { if (user.IsWriteBlocked(config)) return Unauthorized; @@ -47,6 +47,8 @@ public Response SubmitRating(RequestContext context, GameDatabaseContext databas if (rating is > 1 or < -1) return BadRequest; bool rated = database.RateLevel(level, user, (RatingType)rating); + dataContext.Cache.UpdateLevelRatingByUser(user, level, rating, database); + return rated ? OK : Unauthorized; } @@ -55,7 +57,8 @@ public Response SubmitRating(RequestContext context, GameDatabaseContext databas [RequireEmailVerified] [RateLimitSettings(CommonRelationEndpointLimits.TimeoutDuration, CommonRelationEndpointLimits.RequestAmount, CommonRelationEndpointLimits.BlockDuration, CommonRelationEndpointLimits.RequestBucket)] - public Response RateUserLevel(RequestContext context, GameDatabaseContext database, GameUser user, string slotType, int id) + public Response RateUserLevel(RequestContext context, GameDatabaseContext database, GameUser user, string slotType, int id, + DataContext dataContext) { GameLevel? level = database.GetLevelByIdAndType(slotType, id); if (level == null) return NotFound; @@ -87,7 +90,9 @@ public Response RateUserLevel(RequestContext context, GameDatabaseContext databa return BadRequest; } - return database.RateLevel(level, user, rating) ? OK : Unauthorized; + database.RateLevel(level, user, rating); + dataContext.Cache.UpdateLevelRatingByUser(user, level, (int)rating, database); + return OK; } [GameEndpoint("reviewsFor/{slotType}/{id}", ContentType.Xml)] @@ -179,6 +184,7 @@ public Response PostReviewForLevel(RequestContext context, // Update the user's rating if valid if (body.Thumb is > 1 or < -1) return BadRequest; database.RateLevel(level, user, (RatingType)body.Thumb); + dataContext.Cache.UpdateLevelRatingByUser(user, level, body.Thumb, database); // Return the review return new Response(SerializedGameReview.FromOld(review, dataContext), ContentType.Xml);