diff --git a/DysonNetwork.Sphere/Account/AccountController.cs b/DysonNetwork.Sphere/Account/AccountController.cs index 0fbb2ff..05f0d27 100644 --- a/DysonNetwork.Sphere/Account/AccountController.cs +++ b/DysonNetwork.Sphere/Account/AccountController.cs @@ -344,7 +344,7 @@ public class AccountController( if (!isAvailable) return BadRequest("Check-in is not available for today."); - var needsCaptcha = events.CheckInDailyDoAskCaptcha(currentUser); + var needsCaptcha = await events.CheckInDailyDoAskCaptcha(currentUser); return needsCaptcha switch { true when string.IsNullOrWhiteSpace(captchaToken) => StatusCode(423, diff --git a/DysonNetwork.Sphere/Account/AccountEventService.cs b/DysonNetwork.Sphere/Account/AccountEventService.cs index 5366b2e..985be3c 100644 --- a/DysonNetwork.Sphere/Account/AccountEventService.cs +++ b/DysonNetwork.Sphere/Account/AccountEventService.cs @@ -1,11 +1,13 @@ using System.Globalization; using DysonNetwork.Sphere.Activity; using DysonNetwork.Sphere.Connection; +using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Wallet; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Localization; using NodaTime; +using Org.BouncyCastle.Asn1.X509; namespace DysonNetwork.Sphere.Account; @@ -13,24 +15,25 @@ public class AccountEventService( AppDatabase db, ActivityService act, WebSocketService ws, - IMemoryCache cache, + ICacheService cache, PaymentService payment, IStringLocalizer localizer ) { private static readonly Random Random = new(); - private const string StatusCacheKey = "account_status_"; + private const string StatusCacheKey = "AccountStatus_"; public void PurgeStatusCache(Guid userId) { var cacheKey = $"{StatusCacheKey}{userId}"; - cache.Remove(cacheKey); + cache.RemoveAsync(cacheKey); } public async Task GetStatus(Guid userId) { var cacheKey = $"{StatusCacheKey}{userId}"; - if (cache.TryGetValue(cacheKey, out Status? cachedStatus)) + var cachedStatus = await cache.GetAsync(cacheKey); + if (cachedStatus is not null) { cachedStatus!.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId); return cachedStatus; @@ -46,7 +49,8 @@ public class AccountEventService( if (status is not null) { status.IsOnline = !status.IsInvisible && isOnline; - cache.Set(cacheKey, status, TimeSpan.FromMinutes(5)); + await cache.SetWithGroupsAsync(cacheKey, status, [$"{AccountService.AccountCachePrefix}{status.AccountId}"], + TimeSpan.FromMinutes(5)); return status; } @@ -101,17 +105,18 @@ public class AccountEventService( } private const int FortuneTipCount = 7; // This will be the max index for each type (positive/negative) - private const string CaptchaCacheKey = "checkin_captcha_"; + private const string CaptchaCacheKey = "CheckInCaptcha_"; private const int CaptchaProbabilityPercent = 20; - public bool CheckInDailyDoAskCaptcha(Account user) + public async Task CheckInDailyDoAskCaptcha(Account user) { var cacheKey = $"{CaptchaCacheKey}{user.Id}"; - if (cache.TryGetValue(cacheKey, out bool? needsCaptcha)) + var needsCaptcha = await cache.GetAsync(cacheKey); + if (needsCaptcha is not null) return needsCaptcha!.Value; var result = Random.Next(100) < CaptchaProbabilityPercent; - cache.Set(cacheKey, result, TimeSpan.FromHours(24)); + await cache.SetAsync(cacheKey, result, TimeSpan.FromHours(24)); return result; } @@ -132,8 +137,14 @@ public class AccountEventService( return lastDate < currentDate; } + public const string CheckInLockKey = "CheckInLock_"; + public async Task CheckInDaily(Account user) { + var lockKey = $"{CheckInLockKey}{user.Id}"; + var lk = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(10), TimeSpan.Zero); + if (lk is null) throw new InvalidOperationException("Check-in was in progress."); + var cultureInfo = new CultureInfo(user.Language, false); CultureInfo.CurrentCulture = cultureInfo; CultureInfo.CurrentUICulture = cultureInfo; @@ -201,6 +212,7 @@ public class AccountEventService( ActivityVisibility.Friends ); + await lk.ReleaseAsync(); return result; } diff --git a/DysonNetwork.Sphere/Account/AccountService.cs b/DysonNetwork.Sphere/Account/AccountService.cs index ea08357..4f9669f 100644 --- a/DysonNetwork.Sphere/Account/AccountService.cs +++ b/DysonNetwork.Sphere/Account/AccountService.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Reflection; using DysonNetwork.Sphere.Localization; using DysonNetwork.Sphere.Permission; +using DysonNetwork.Sphere.Storage; using EFCore.BulkExtensions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; @@ -12,20 +13,15 @@ namespace DysonNetwork.Sphere.Account; public class AccountService( AppDatabase db, - IMemoryCache cache, + ICacheService cache, IStringLocalizerFactory factory ) { + public const string AccountCachePrefix = "Account_"; + public async Task PurgeAccountCache(Account account) { - cache.Remove($"UserFriends_{account.Id}"); - - var sessions = await db.AuthSessions.Where(e => e.Account.Id == account.Id).Select(e => e.Id) - .ToListAsync(); - foreach (var session in sessions) - { - cache.Remove($"Auth_{session}"); - } + await cache.RemoveGroupAsync($"{AccountCachePrefix}{account.Id}"); } public async Task LookupAccount(string probe) diff --git a/DysonNetwork.Sphere/Account/RelationshipService.cs b/DysonNetwork.Sphere/Account/RelationshipService.cs index 7bfadb2..5e9262f 100644 --- a/DysonNetwork.Sphere/Account/RelationshipService.cs +++ b/DysonNetwork.Sphere/Account/RelationshipService.cs @@ -1,11 +1,13 @@ +using DysonNetwork.Sphere.Storage; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Memory; using NodaTime; namespace DysonNetwork.Sphere.Account; -public class RelationshipService(AppDatabase db, IMemoryCache cache) +public class RelationshipService(AppDatabase db, ICacheService cache) { + private const string UserFriendsCacheKeyPrefix = "UserFriends_"; + public async Task HasExistingRelationship(Guid accountId, Guid relatedId) { var count = await db.AccountRelationships @@ -49,8 +51,8 @@ public class RelationshipService(AppDatabase db, IMemoryCache cache) db.AccountRelationships.Add(relationship); await db.SaveChangesAsync(); - cache.Remove($"UserFriends_{relationship.AccountId}"); - cache.Remove($"UserFriends_{relationship.RelatedId}"); + await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relationship.AccountId}"); + await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relationship.RelatedId}"); return relationship; } @@ -89,8 +91,8 @@ public class RelationshipService(AppDatabase db, IMemoryCache cache) db.AccountRelationships.Remove(relationship); await db.SaveChangesAsync(); - cache.Remove($"UserFriends_{accountId}"); - cache.Remove($"UserFriends_{relatedId}"); + await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}"); + await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}"); } public async Task AcceptFriendRelationship( @@ -119,8 +121,8 @@ public class RelationshipService(AppDatabase db, IMemoryCache cache) await db.SaveChangesAsync(); - cache.Remove($"UserFriends_{relationship.AccountId}"); - cache.Remove($"UserFriends_{relationship.RelatedId}"); + await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relationship.AccountId}"); + await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relationship.RelatedId}"); return relationshipBackward; } @@ -133,21 +135,27 @@ public class RelationshipService(AppDatabase db, IMemoryCache cache) relationship.Status = status; db.Update(relationship); await db.SaveChangesAsync(); - cache.Remove($"UserFriends_{accountId}"); - cache.Remove($"UserFriends_{relatedId}"); + + await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}"); + await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}"); + return relationship; } public async Task> ListAccountFriends(Account account) { - if (!cache.TryGetValue($"UserFriends_{account.Id}", out List? friends)) + string cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}"; + var friends = await cache.GetAsync>(cacheKey); + + if (friends == null) { friends = await db.AccountRelationships .Where(r => r.RelatedId == account.Id) .Where(r => r.Status == RelationshipStatus.Friends) .Select(r => r.AccountId) .ToListAsync(); - cache.Set($"UserFriends_{account.Id}", friends, TimeSpan.FromHours(1)); + + await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1)); } return friends ?? []; diff --git a/DysonNetwork.Sphere/Auth/UserInfoMiddleware.cs b/DysonNetwork.Sphere/Auth/UserInfoMiddleware.cs index 6c949c4..ea6a552 100644 --- a/DysonNetwork.Sphere/Auth/UserInfoMiddleware.cs +++ b/DysonNetwork.Sphere/Auth/UserInfoMiddleware.cs @@ -1,27 +1,30 @@ +using DysonNetwork.Sphere.Account; +using DysonNetwork.Sphere.Storage; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Memory; namespace DysonNetwork.Sphere.Auth; -public class UserInfoMiddleware(RequestDelegate next, IMemoryCache cache) +public class UserInfoMiddleware(RequestDelegate next, ICacheService cache) { public async Task InvokeAsync(HttpContext context, AppDatabase db) { var sessionIdClaim = context.User.FindFirst("session_id")?.Value; if (sessionIdClaim is not null && Guid.TryParse(sessionIdClaim, out var sessionId)) { - if (!cache.TryGetValue($"Auth_{sessionId}", out Session? session)) + var session = await cache.GetAsync($"Auth_{sessionId}"); + if (session is null) { session = await db.AuthSessions + .Where(e => e.Id == sessionId) .Include(e => e.Challenge) .Include(e => e.Account) - .Include(e => e.Account.Profile) - .Where(e => e.Id == sessionId) + .ThenInclude(e => e.Profile) .FirstOrDefaultAsync(); if (session is not null) { - cache.Set($"Auth_{sessionId}", session, TimeSpan.FromHours(1)); + await cache.SetWithGroupsAsync($"Auth_{sessionId}", session, + [$"{AccountService.AccountCachePrefix}{session.Account.Id}"], TimeSpan.FromHours(1)); } } diff --git a/DysonNetwork.Sphere/Chat/ChatRoomService.cs b/DysonNetwork.Sphere/Chat/ChatRoomService.cs index 5fa91fc..f723660 100644 --- a/DysonNetwork.Sphere/Chat/ChatRoomService.cs +++ b/DysonNetwork.Sphere/Chat/ChatRoomService.cs @@ -1,37 +1,40 @@ using DysonNetwork.Sphere.Account; +using DysonNetwork.Sphere.Storage; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Memory; using NodaTime; namespace DysonNetwork.Sphere.Chat; -public class ChatRoomService(AppDatabase db, IMemoryCache cache) +public class ChatRoomService(AppDatabase db, ICacheService cache) { - private const string RoomMembersCacheKey = "ChatRoomMembers_{0}"; + public const string ChatRoomGroupPrefix = "ChatRoom_"; + private const string RoomMembersCacheKeyPrefix = "ChatRoomMembers_"; public async Task> ListRoomMembers(Guid roomId) { - var cacheKey = string.Format(RoomMembersCacheKey, roomId); - if (cache.TryGetValue(cacheKey, out List? cachedMembers)) - return cachedMembers!; + var cacheKey = RoomMembersCacheKeyPrefix + roomId; + var cachedMembers = await cache.GetAsync>(cacheKey); + if (cachedMembers != null) + return cachedMembers; var members = await db.ChatMembers .Where(m => m.ChatRoomId == roomId) .Where(m => m.JoinedAt != null) .Where(m => m.LeaveAt == null) .ToListAsync(); - - var cacheOptions = new MemoryCacheEntryOptions() - .SetAbsoluteExpiration(TimeSpan.FromMinutes(5)); - cache.Set(cacheKey, members, cacheOptions); + + var chatRoomGroup = ChatRoomGroupPrefix + roomId; + await cache.SetWithGroupsAsync(cacheKey, members, + new[] { chatRoomGroup }, + TimeSpan.FromMinutes(5)); return members; } - public void PurgeRoomMembersCache(Guid roomId) + public async Task PurgeRoomMembersCache(Guid roomId) { - var cacheKey = string.Format(RoomMembersCacheKey, roomId); - cache.Remove(cacheKey); + var chatRoomGroup = ChatRoomGroupPrefix + roomId; + await cache.RemoveGroupAsync(chatRoomGroup); } public async Task> SortChatRoomByLastMessage(List rooms) diff --git a/DysonNetwork.Sphere/Connection/Handlers/MessageReadHandler.cs b/DysonNetwork.Sphere/Connection/Handlers/MessageReadHandler.cs index f157f1a..625cff0 100644 --- a/DysonNetwork.Sphere/Connection/Handlers/MessageReadHandler.cs +++ b/DysonNetwork.Sphere/Connection/Handlers/MessageReadHandler.cs @@ -10,7 +10,7 @@ namespace DysonNetwork.Sphere.Connection.Handlers; public class MessageReadHandler( AppDatabase db, - IMemoryCache cache, + ICacheService cache, ChatRoomService crs, FlushBufferService buffer ) @@ -44,11 +44,9 @@ public class MessageReadHandler( return; } - ChatMember? sender; var cacheKey = string.Format(ChatMemberCacheKey, currentUser.Id, request.ChatRoomId); - if (cache.TryGetValue(cacheKey, out ChatMember? cachedMember)) - sender = cachedMember; - else + var sender = await cache.GetAsync(cacheKey); + if (sender is null) { sender = await db.ChatMembers .Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == request.ChatRoomId) @@ -56,9 +54,10 @@ public class MessageReadHandler( if (sender != null) { - var cacheOptions = new MemoryCacheEntryOptions() - .SetAbsoluteExpiration(TimeSpan.FromMinutes(5)); - cache.Set(cacheKey, sender, cacheOptions); + var chatRoomGroup = ChatRoomService.ChatRoomGroupPrefix + request.ChatRoomId; + await cache.SetWithGroupsAsync(cacheKey, sender, + [chatRoomGroup], + TimeSpan.FromMinutes(5)); } } diff --git a/DysonNetwork.Sphere/Connection/Handlers/MessageTypingHandler.cs b/DysonNetwork.Sphere/Connection/Handlers/MessageTypingHandler.cs index 24ec0ea..7bc43b8 100644 --- a/DysonNetwork.Sphere/Connection/Handlers/MessageTypingHandler.cs +++ b/DysonNetwork.Sphere/Connection/Handlers/MessageTypingHandler.cs @@ -1,11 +1,12 @@ using System.Net.WebSockets; using DysonNetwork.Sphere.Chat; +using DysonNetwork.Sphere.Storage; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; namespace DysonNetwork.Sphere.Connection.Handlers; -public class MessageTypingHandler(AppDatabase db, ChatRoomService crs, IMemoryCache cache) : IWebSocketPacketHandler +public class MessageTypingHandler(AppDatabase db, ChatRoomService crs, ICacheService cache) : IWebSocketPacketHandler { public string PacketType => "messages.typing"; @@ -33,11 +34,9 @@ public class MessageTypingHandler(AppDatabase db, ChatRoomService crs, IMemoryCa return; } - ChatMember? sender = null; var cacheKey = string.Format(MessageReadHandler.ChatMemberCacheKey, currentUser.Id, request.ChatRoomId); - if (cache.TryGetValue(cacheKey, out ChatMember? cachedMember)) - sender = cachedMember; - else + var sender = await cache.GetAsync(cacheKey); + if (sender is null) { sender = await db.ChatMembers .Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == request.ChatRoomId) @@ -45,9 +44,10 @@ public class MessageTypingHandler(AppDatabase db, ChatRoomService crs, IMemoryCa if (sender != null) { - var cacheOptions = new MemoryCacheEntryOptions() - .SetAbsoluteExpiration(TimeSpan.FromMinutes(5)); - cache.Set(cacheKey, sender, cacheOptions); + var chatRoomGroup = ChatRoomService.ChatRoomGroupPrefix + request.ChatRoomId; + await cache.SetWithGroupsAsync(cacheKey, sender, + [chatRoomGroup], + TimeSpan.FromMinutes(5)); } } diff --git a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj index a4a7481..6833c32 100644 --- a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj +++ b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj @@ -53,6 +53,8 @@ + + diff --git a/DysonNetwork.Sphere/Permission/PermissionService.cs b/DysonNetwork.Sphere/Permission/PermissionService.cs index e0bf4ef..68c4424 100644 --- a/DysonNetwork.Sphere/Permission/PermissionService.cs +++ b/DysonNetwork.Sphere/Permission/PermissionService.cs @@ -1,22 +1,29 @@ -using Microsoft.Extensions.Caching.Memory; -using System.Text.Json; using Microsoft.EntityFrameworkCore; using NodaTime; +using System.Text.Json; +using DysonNetwork.Sphere.Storage; namespace DysonNetwork.Sphere.Permission; public class PermissionService( AppDatabase db, - IMemoryCache cache, + ICacheService cache, ILogger logger) { private static readonly TimeSpan CacheExpiration = TimeSpan.FromMinutes(1); + + private const string PermCacheKeyPrefix = "Perm_"; + private const string PermGroupCacheKeyPrefix = "PermCacheGroup_"; + private const string PermissionGroupPrefix = "PermGroup_"; - private string GetPermissionCacheKey(string actor, string area, string key) => - $"perm:{actor}:{area}:{key}"; + private static string _GetPermissionCacheKey(string actor, string area, string key) => + PermCacheKeyPrefix + actor + ":" + area + ":" + key; - private string GetGroupsCacheKey(string actor) => - $"perm_groups:{actor}"; + private static string _GetGroupsCacheKey(string actor) => + PermGroupCacheKeyPrefix + actor; + + private static string _GetPermissionGroupKey(string actor) => + PermissionGroupPrefix + actor; public async Task HasPermissionAsync(string actor, string area, string key) { @@ -26,26 +33,31 @@ public class PermissionService( public async Task GetPermissionAsync(string actor, string area, string key) { - var cacheKey = GetPermissionCacheKey(actor, area, key); + var cacheKey = _GetPermissionCacheKey(actor, area, key); - if (cache.TryGetValue(cacheKey, out var cachedValue)) + var cachedValue = await cache.GetAsync(cacheKey); + if (cachedValue != null) { return cachedValue; } var now = SystemClock.Instance.GetCurrentInstant(); - var groupsKey = GetGroupsCacheKey(actor); + var groupsKey = _GetGroupsCacheKey(actor); - var groupsId = await cache.GetOrCreateAsync(groupsKey, async entry => + var groupsId = await cache.GetAsync>(groupsKey); + if (groupsId == null) { - entry.AbsoluteExpirationRelativeToNow = CacheExpiration; - return await db.PermissionGroupMembers + groupsId = await db.PermissionGroupMembers .Where(n => n.Actor == actor) .Where(n => n.ExpiredAt == null || n.ExpiredAt < now) .Where(n => n.AffectedAt == null || n.AffectedAt >= now) .Select(e => e.GroupId) .ToListAsync(); - }); + + await cache.SetWithGroupsAsync(groupsKey, groupsId, + new[] { _GetPermissionGroupKey(actor) }, + CacheExpiration); + } var permission = await db.PermissionNodes .Where(n => n.GroupId == null || groupsId.Contains(n.GroupId.Value)) @@ -56,7 +68,9 @@ public class PermissionService( var result = permission is not null ? _DeserializePermissionValue(permission.Value) : default; - cache.Set(cacheKey, result, CacheExpiration); + await cache.SetWithGroupsAsync(cacheKey, result, + new[] { _GetPermissionGroupKey(actor) }, + CacheExpiration); return result; } @@ -86,7 +100,7 @@ public class PermissionService( await db.SaveChangesAsync(); // Invalidate related caches - InvalidatePermissionCache(actor, area, key); + await InvalidatePermissionCacheAsync(actor, area, key); return node; } @@ -119,8 +133,9 @@ public class PermissionService( await db.SaveChangesAsync(); // Invalidate related caches - InvalidatePermissionCache(actor, area, key); - cache.Remove(GetGroupsCacheKey(actor)); + await InvalidatePermissionCacheAsync(actor, area, key); + await cache.RemoveAsync(_GetGroupsCacheKey(actor)); + await cache.RemoveGroupAsync(_GetPermissionGroupKey(actor)); return node; } @@ -134,7 +149,7 @@ public class PermissionService( await db.SaveChangesAsync(); // Invalidate cache - InvalidatePermissionCache(actor, area, key); + await InvalidatePermissionCacheAsync(actor, area, key); } public async Task RemovePermissionNodeFromGroup(PermissionGroup group, string actor, string area, string key) @@ -148,14 +163,15 @@ public class PermissionService( await db.SaveChangesAsync(); // Invalidate caches - InvalidatePermissionCache(actor, area, key); - cache.Remove(GetGroupsCacheKey(actor)); + await InvalidatePermissionCacheAsync(actor, area, key); + await cache.RemoveAsync(_GetGroupsCacheKey(actor)); + await cache.RemoveGroupAsync(_GetPermissionGroupKey(actor)); } - private void InvalidatePermissionCache(string actor, string area, string key) + private async Task InvalidatePermissionCacheAsync(string actor, string area, string key) { - var cacheKey = GetPermissionCacheKey(actor, area, key); - cache.Remove(cacheKey); + var cacheKey = _GetPermissionCacheKey(actor, area, key); + await cache.RemoveAsync(cacheKey); } private static T? _DeserializePermissionValue(JsonDocument json) diff --git a/DysonNetwork.Sphere/Program.cs b/DysonNetwork.Sphere/Program.cs index b2355f8..a96327f 100644 --- a/DysonNetwork.Sphere/Program.cs +++ b/DysonNetwork.Sphere/Program.cs @@ -29,6 +29,7 @@ using Microsoft.OpenApi.Models; using NodaTime; using NodaTime.Serialization.SystemTextJson; using Quartz; +using StackExchange.Redis; using tusdotnet; using tusdotnet.Models; using tusdotnet.Models.Configuration; @@ -44,6 +45,13 @@ builder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize = builder.Services.AddLocalization(options => options.ResourcesPath = "Resources"); builder.Services.AddDbContext(); +builder.Services.AddSingleton(sp => +{ + var connection = builder.Configuration.GetConnectionString("FastRetrieve")!; + return ConnectionMultiplexer.Connect(connection); +}); + +builder.Services.AddScoped(); builder.Services.AddHttpClient(); builder.Services.AddControllers().AddJsonOptions(options => diff --git a/DysonNetwork.Sphere/Publisher/PublisherService.cs b/DysonNetwork.Sphere/Publisher/PublisherService.cs index d8e0a7f..a43e2a3 100644 --- a/DysonNetwork.Sphere/Publisher/PublisherService.cs +++ b/DysonNetwork.Sphere/Publisher/PublisherService.cs @@ -6,7 +6,7 @@ using NodaTime; namespace DysonNetwork.Sphere.Publisher; -public class PublisherService(AppDatabase db, FileService fs, IMemoryCache cache) +public class PublisherService(AppDatabase db, FileService fs, ICacheService cache) { public async Task CreateIndividualPublisher( Account.Account account, @@ -101,7 +101,8 @@ public class PublisherService(AppDatabase db, FileService fs, IMemoryCache cache public async Task GetPublisherStats(string name) { var cacheKey = string.Format(PublisherStatsCacheKey, name); - if (cache.TryGetValue(cacheKey, out PublisherStats? stats)) + var stats = await cache.GetAsync(cacheKey); + if (stats is not null) return stats; var publisher = await db.Publishers.FirstOrDefaultAsync(e => e.Name == name); @@ -133,7 +134,7 @@ public class PublisherService(AppDatabase db, FileService fs, IMemoryCache cache SubscribersCount = subscribersCount, }; - cache.Set(cacheKey, stats, TimeSpan.FromMinutes(5)); + await cache.SetAsync(cacheKey, stats, TimeSpan.FromMinutes(5)); return stats; } @@ -157,15 +158,16 @@ public class PublisherService(AppDatabase db, FileService fs, IMemoryCache cache } await db.SaveChangesAsync(); - cache.Remove(string.Format(PublisherFeatureCacheKey, publisherId, flag)); + await cache.RemoveAsync(string.Format(PublisherFeatureCacheKey, publisherId, flag)); } public async Task HasFeature(Guid publisherId, string flag) { var cacheKey = string.Format(PublisherFeatureCacheKey, publisherId, flag); - if (cache.TryGetValue(cacheKey, out bool isEnabled)) - return isEnabled; + var isEnabled = await cache.GetAsync(cacheKey); + if (isEnabled.HasValue) + return isEnabled.Value; var now = SystemClock.Instance.GetCurrentInstant(); var featureFlag = await db.PublisherFeatures @@ -175,17 +177,17 @@ public class PublisherService(AppDatabase db, FileService fs, IMemoryCache cache ); if (featureFlag is not null) isEnabled = true; - cache.Set(cacheKey, isEnabled, TimeSpan.FromMinutes(5)); - return isEnabled; + await cache.SetAsync(cacheKey, isEnabled!.Value, TimeSpan.FromMinutes(5)); + return isEnabled.Value; } - + public async Task IsMemberWithRole(Guid publisherId, Guid accountId, PublisherMemberRole requiredRole) { var member = await db.Publishers .Where(p => p.Id == publisherId) .SelectMany(p => p.Members) .FirstOrDefaultAsync(m => m.AccountId == accountId); - + return member != null && member.Role >= requiredRole; } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Sticker/StickerService.cs b/DysonNetwork.Sphere/Sticker/StickerService.cs index 91788b9..82491c8 100644 --- a/DysonNetwork.Sphere/Sticker/StickerService.cs +++ b/DysonNetwork.Sphere/Sticker/StickerService.cs @@ -1,6 +1,5 @@ using DysonNetwork.Sphere.Storage; using Microsoft.EntityFrameworkCore; - using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using System; @@ -8,19 +7,21 @@ using System.Linq; using System.Threading.Tasks; namespace DysonNetwork.Sphere.Sticker; -public class StickerService(AppDatabase db, FileService fs, IMemoryCache cache) + +public class StickerService(AppDatabase db, FileService fs, ICacheService cache) { private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15); - + public async Task CreateStickerAsync(Sticker sticker) { db.Stickers.Add(sticker); await db.SaveChangesAsync(); - + await fs.MarkUsageAsync(sticker.Image, 1); return sticker; } + public async Task UpdateStickerAsync(Sticker sticker, CloudFile? newImage) { if (newImage != null) @@ -32,53 +33,54 @@ public class StickerService(AppDatabase db, FileService fs, IMemoryCache cache) db.Stickers.Update(sticker); await db.SaveChangesAsync(); - + // Invalidate cache for this sticker - PurgeStickerCache(sticker); + await PurgeStickerCache(sticker); return sticker; } + public async Task DeleteStickerAsync(Sticker sticker) { db.Stickers.Remove(sticker); await db.SaveChangesAsync(); await fs.MarkUsageAsync(sticker.Image, -1); - + // Invalidate cache for this sticker - PurgeStickerCache(sticker); + await PurgeStickerCache(sticker); } + public async Task DeleteStickerPackAsync(StickerPack pack) { var stickers = await db.Stickers .Include(s => s.Image) .Where(s => s.PackId == pack.Id) .ToListAsync(); - + var images = stickers.Select(s => s.Image).ToList(); - + db.Stickers.RemoveRange(stickers); db.StickerPacks.Remove(pack); await db.SaveChangesAsync(); - + await fs.MarkUsageRangeAsync(images, -1); - + // Invalidate cache for all stickers in this pack foreach (var sticker in stickers) { PurgeStickerCache(sticker); } } - + public async Task LookupStickerByIdentifierAsync(string identifier) { identifier = identifier.ToLower(); // Try to get from the cache first var cacheKey = $"StickerLookup_{identifier}"; - if (cache.TryGetValue(cacheKey, out Sticker? cachedSticker)) - { + var cachedSticker = await cache.GetAsync(cacheKey); + if (cachedSticker is not null) return cachedSticker; - } - + // If not in cache, fetch from the database IQueryable query = db.Stickers .Include(e => e.Pack) @@ -86,20 +88,20 @@ public class StickerService(AppDatabase db, FileService fs, IMemoryCache cache) query = Guid.TryParse(identifier, out var guid) ? query.Where(e => e.Id == guid) : query.Where(e => (e.Pack.Prefix + e.Slug).ToLower() == identifier); - + var sticker = await query.FirstOrDefaultAsync(); - + // Store in cache if found if (sticker != null) - cache.Set(cacheKey, sticker, CacheDuration); - + await cache.SetAsync(cacheKey, sticker, CacheDuration); + return sticker; } - - private void PurgeStickerCache(Sticker sticker) + + private async Task PurgeStickerCache(Sticker sticker) { // Remove both possible cache entries - cache.Remove($"StickerLookup_{sticker.Id}"); - cache.Remove($"StickerLookup_{sticker.Pack.Prefix}{sticker.Slug}"); + await cache.RemoveAsync($"StickerLookup_{sticker.Id}"); + await cache.RemoveAsync($"StickerLookup_{sticker.Pack.Prefix}{sticker.Slug}"); } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Storage/CacheService.cs b/DysonNetwork.Sphere/Storage/CacheService.cs new file mode 100644 index 0000000..61b0cfc --- /dev/null +++ b/DysonNetwork.Sphere/Storage/CacheService.cs @@ -0,0 +1,356 @@ +using System.Text.Json; +using StackExchange.Redis; + +namespace DysonNetwork.Sphere.Storage; + +/// +/// Represents a distributed lock that can be used to synchronize access across multiple processes +/// +public interface IDistributedLock : IAsyncDisposable +{ + /// + /// The resource identifier this lock is protecting + /// + string Resource { get; } + + /// + /// Unique identifier for this lock instance + /// + string LockId { get; } + + /// + /// Extends the lock's expiration time + /// + Task ExtendAsync(TimeSpan timeSpan); + + /// + /// Releases the lock immediately + /// + Task ReleaseAsync(); +} + +public interface ICacheService +{ + /// + /// Sets a value in the cache with an optional expiration time + /// + Task SetAsync(string key, T value, TimeSpan? expiry = null); + + /// + /// Gets a value from the cache + /// + Task GetAsync(string key); + + /// + /// Removes a specific key from the cache + /// + Task RemoveAsync(string key); + + /// + /// Adds a key to a group for group-based operations + /// + Task AddToGroupAsync(string key, string group); + + /// + /// Removes all keys associated with a specific group + /// + Task RemoveGroupAsync(string group); + + /// + /// Gets all keys belonging to a specific group + /// + Task> GetGroupKeysAsync(string group); + + /// + /// Helper method to set a value in cache and associate it with multiple groups in one operation + /// + /// The type of value being cached + /// Cache key + /// The value to cache + /// Optional collection of group names to associate the key with + /// Optional expiration time for the cached item + /// True if the set operation was successful + Task SetWithGroupsAsync(string key, T value, IEnumerable? groups = null, TimeSpan? expiry = null); + + /// + /// Acquires a distributed lock on the specified resource + /// + /// The resource identifier to lock + /// How long the lock should be held before automatically expiring + /// How long to wait for the lock before giving up + /// How often to retry acquiring the lock during the wait time + /// A distributed lock instance if acquired, null otherwise + Task AcquireLockAsync(string resource, TimeSpan expiry, TimeSpan? waitTime = null, TimeSpan? retryInterval = null); + + /// + /// Executes an action with a distributed lock, ensuring the lock is properly released afterwards + /// + /// The resource identifier to lock + /// The action to execute while holding the lock + /// How long the lock should be held before automatically expiring + /// How long to wait for the lock before giving up + /// How often to retry acquiring the lock during the wait time + /// True if the lock was acquired and the action was executed, false otherwise + Task ExecuteWithLockAsync(string resource, Func action, TimeSpan expiry, TimeSpan? waitTime = null, TimeSpan? retryInterval = null); + + /// + /// Executes a function with a distributed lock, ensuring the lock is properly released afterwards + /// + /// The return type of the function + /// The resource identifier to lock + /// The function to execute while holding the lock + /// How long the lock should be held before automatically expiring + /// How long to wait for the lock before giving up + /// How often to retry acquiring the lock during the wait time + /// The result of the function if the lock was acquired, default(T) otherwise + Task<(bool Acquired, T? Result)> ExecuteWithLockAsync(string resource, Func> func, TimeSpan expiry, TimeSpan? waitTime = null, TimeSpan? retryInterval = null); +} + +public class RedisDistributedLock : IDistributedLock +{ + private readonly IDatabase _database; + private bool _disposed; + + private const string LockKeyPrefix = "Lock_"; + + public string Resource { get; } + public string LockId { get; } + + internal RedisDistributedLock(IDatabase database, string resource, string lockId) + { + _database = database; + Resource = resource; + LockId = lockId; + } + + public async Task ExtendAsync(TimeSpan timeSpan) + { + if (_disposed) + throw new ObjectDisposedException(nameof(RedisDistributedLock)); + + var script = @" + if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('pexpire', KEYS[1], ARGV[2]) + else + return 0 + end + "; + + var result = await _database.ScriptEvaluateAsync( + script, + new RedisKey[] { $"{LockKeyPrefix}{Resource}" }, + new RedisValue[] { LockId, (long)timeSpan.TotalMilliseconds } + ); + + return (long)result! == 1; + } + + public async Task ReleaseAsync() + { + if (_disposed) + return; + + var script = @" + if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('del', KEYS[1]) + else + return 0 + end + "; + + await _database.ScriptEvaluateAsync( + script, + new RedisKey[] { $"{LockKeyPrefix}{Resource}" }, + new RedisValue[] { LockId } + ); + + _disposed = true; + } + + public async ValueTask DisposeAsync() + { + await ReleaseAsync(); + GC.SuppressFinalize(this); + } +} + +public class CacheServiceRedis : ICacheService +{ + private readonly IDatabase _database; + private readonly JsonSerializerOptions _serializerOptions; + + // Using prefixes for different types of keys + private const string GroupKeyPrefix = "CacheGroup_"; + private const string LockKeyPrefix = "Lock_"; + + public CacheServiceRedis(IConnectionMultiplexer redis) + { + var rds = redis ?? throw new ArgumentNullException(nameof(redis)); + _database = rds.GetDatabase(); + _serializerOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + } + + public async Task SetAsync(string key, T value, TimeSpan? expiry = null) + { + if (string.IsNullOrEmpty(key)) + throw new ArgumentException("Key cannot be null or empty", nameof(key)); + + var serializedValue = JsonSerializer.Serialize(value, _serializerOptions); + return await _database.StringSetAsync(key, serializedValue, expiry); + } + + public async Task GetAsync(string key) + { + if (string.IsNullOrEmpty(key)) + throw new ArgumentException("Key cannot be null or empty", nameof(key)); + + var value = await _database.StringGetAsync(key); + + if (value.IsNullOrEmpty) + return default; + + return JsonSerializer.Deserialize(value!, _serializerOptions); + } + + public async Task RemoveAsync(string key) + { + if (string.IsNullOrEmpty(key)) + throw new ArgumentException("Key cannot be null or empty", nameof(key)); + + // Before removing the key, find all groups it belongs to and remove it from them + var script = @" + local groups = redis.call('KEYS', ARGV[1]) + for _, group in ipairs(groups) do + redis.call('SREM', group, ARGV[2]) + end + return redis.call('DEL', ARGV[2]) + "; + + var result = await _database.ScriptEvaluateAsync( + script, + values: new RedisValue[] { $"{GroupKeyPrefix}*", key } + ); + + return (long)result! > 0; + } + + public async Task AddToGroupAsync(string key, string group) + { + if (string.IsNullOrEmpty(key)) + throw new ArgumentException(@"Key cannot be null or empty.", nameof(key)); + + if (string.IsNullOrEmpty(group)) + throw new ArgumentException(@"Group cannot be null or empty.", nameof(group)); + + var groupKey = $"{GroupKeyPrefix}{group}"; + await _database.SetAddAsync(groupKey, key); + } + + public async Task RemoveGroupAsync(string group) + { + if (string.IsNullOrEmpty(group)) + throw new ArgumentException(@"Group cannot be null or empty.", nameof(group)); + + var groupKey = $"{GroupKeyPrefix}{group}"; + + // Get all keys in the group + var keys = await _database.SetMembersAsync(groupKey); + + if (keys.Length > 0) + { + // Delete all the keys + var keysTasks = keys.Select(key => _database.KeyDeleteAsync(key.ToString())); + await Task.WhenAll(keysTasks); + } + + // Delete the group itself + await _database.KeyDeleteAsync(groupKey); + } + + public async Task> GetGroupKeysAsync(string group) + { + if (string.IsNullOrEmpty(group)) + throw new ArgumentException(@"Group cannot be null or empty.", nameof(group)); + + var groupKey = $"{GroupKeyPrefix}{group}"; + var members = await _database.SetMembersAsync(groupKey); + + return members.Select(m => m.ToString()); + } + + public async Task SetWithGroupsAsync(string key, T value, IEnumerable? groups = null, TimeSpan? expiry = null) + { + // First set the value in the cache + var setResult = await SetAsync(key, value, expiry); + + // If successful and there are groups to associate, add the key to each group + if (setResult && groups != null) + { + var groupsArray = groups.Where(g => !string.IsNullOrEmpty(g)).ToArray(); + if (groupsArray.Length > 0) + { + var tasks = groupsArray.Select(group => AddToGroupAsync(key, group)); + await Task.WhenAll(tasks); + } + } + + return setResult; + } + + public async Task AcquireLockAsync(string resource, TimeSpan expiry, TimeSpan? waitTime = null, TimeSpan? retryInterval = null) + { + if (string.IsNullOrEmpty(resource)) + throw new ArgumentException("Resource cannot be null or empty", nameof(resource)); + + var lockKey = $"{LockKeyPrefix}{resource}"; + var lockId = Guid.NewGuid().ToString("N"); + var waitTimeSpan = waitTime ?? TimeSpan.Zero; + var retryIntervalSpan = retryInterval ?? TimeSpan.FromMilliseconds(100); + + var startTime = DateTime.UtcNow; + var acquired = false; + + // Try to acquire the lock, retry until waitTime is exceeded + while (!acquired && (DateTime.UtcNow - startTime) < waitTimeSpan) + { + acquired = await _database.StringSetAsync(lockKey, lockId, expiry, When.NotExists); + + if (!acquired) + { + await Task.Delay(retryIntervalSpan); + } + } + + if (!acquired) + { + return null; // Could not acquire the lock within the wait time + } + + return new RedisDistributedLock(_database, resource, lockId); + } + + public async Task ExecuteWithLockAsync(string resource, Func action, TimeSpan expiry, TimeSpan? waitTime = null, TimeSpan? retryInterval = null) + { + await using var lockObj = await AcquireLockAsync(resource, expiry, waitTime, retryInterval); + + if (lockObj == null) + return false; // Could not acquire the lock + + await action(); + return true; + } + + public async Task<(bool Acquired, T? Result)> ExecuteWithLockAsync(string resource, Func> func, TimeSpan expiry, TimeSpan? waitTime = null, TimeSpan? retryInterval = null) + { + await using var lockObj = await AcquireLockAsync(resource, expiry, waitTime, retryInterval); + + if (lockObj == null) + return (false, default); // Could not acquire the lock + + var result = await func(); + return (true, result); + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Storage/FileService.cs b/DysonNetwork.Sphere/Storage/FileService.cs index 7a3a0fe..fddc90b 100644 --- a/DysonNetwork.Sphere/Storage/FileService.cs +++ b/DysonNetwork.Sphere/Storage/FileService.cs @@ -17,12 +17,12 @@ public class FileService( TusDiskStore store, ILogger logger, IServiceScopeFactory scopeFactory, - IMemoryCache cache + ICacheService cache ) { private const string CacheKeyPrefix = "cloudfile_"; private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15); - + /// /// The api for getting file meta with cache, /// the best use case is for accessing the file data. @@ -34,18 +34,19 @@ public class FileService( public async Task GetFileAsync(string fileId) { var cacheKey = $"{CacheKeyPrefix}{fileId}"; - - if (cache.TryGetValue(cacheKey, out CloudFile? cachedFile)) + + var cachedFile = await cache.GetAsync(cacheKey); + if (cachedFile is not null) return cachedFile; - + var file = await db.Files.FirstOrDefaultAsync(f => f.Id == fileId); - + if (file != null) - cache.Set(cacheKey, file, CacheDuration); - + await cache.SetAsync(cacheKey, file, CacheDuration); + return file; } - + private static readonly string TempFilePrefix = "dyn-cloudfile"; // The analysis file method no longer will remove the GPS EXIF data @@ -83,7 +84,7 @@ public class FileService( file.FileMeta = existingFile.FileMeta; file.HasCompression = existingFile.HasCompression; file.SensitiveMarks = existingFile.SensitiveMarks; - + db.Files.Add(file); await db.SaveChangesAsync(); return file; @@ -399,8 +400,7 @@ public class FileService( ) ); } - - + public async Task SetExpiresRangeAsync(ICollection files, Duration? duration) { @@ -408,55 +408,55 @@ public class FileService( await db.Files.Where(o => ids.Contains(o.Id)) .ExecuteUpdateAsync(setter => setter.SetProperty( b => b.ExpiredAt, - duration.HasValue + duration.HasValue ? b => SystemClock.Instance.GetCurrentInstant() + duration.Value : _ => null ) ); } - - public async Task<(ICollection current, ICollection added, ICollection removed)> DiffAndMarkFilesAsync( - ICollection? newFileIds, - ICollection? previousFiles = null - ) + + public async Task<(ICollection current, ICollection added, ICollection removed)> + DiffAndMarkFilesAsync( + ICollection? newFileIds, + ICollection? previousFiles = null + ) { if (newFileIds == null) return ([], [], previousFiles ?? []); - + var records = await db.Files.Where(f => newFileIds.Contains(f.Id)).ToListAsync(); var previous = previousFiles?.ToDictionary(f => f.Id) ?? new Dictionary(); var current = records.ToDictionary(f => f.Id); - + var added = current.Keys.Except(previous.Keys).Select(id => current[id]).ToList(); var removed = previous.Keys.Except(current.Keys).Select(id => previous[id]).ToList(); - + if (added.Count > 0) await MarkUsageRangeAsync(added, 1); if (removed.Count > 0) await MarkUsageRangeAsync(removed, -1); - + return (newFileIds.Select(id => current[id]).ToList(), added, removed); } - - public async Task<(ICollection current, ICollection added, ICollection removed)> DiffAndSetExpiresAsync( - ICollection? newFileIds, - Duration? duration, - ICollection? previousFiles = null - ) + + public async Task<(ICollection current, ICollection added, ICollection removed)> + DiffAndSetExpiresAsync( + ICollection? newFileIds, + Duration? duration, + ICollection? previousFiles = null + ) { if (newFileIds == null) return ([], [], previousFiles ?? []); - + var records = await db.Files.Where(f => newFileIds.Contains(f.Id)).ToListAsync(); var previous = previousFiles?.ToDictionary(f => f.Id) ?? new Dictionary(); var current = records.ToDictionary(f => f.Id); - + var added = current.Keys.Except(previous.Keys).Select(id => current[id]).ToList(); var removed = previous.Keys.Except(current.Keys).Select(id => previous[id]).ToList(); - + if (added.Count > 0) await SetExpiresRangeAsync(added, duration); if (removed.Count > 0) await SetExpiresRangeAsync(removed, null); - + return (newFileIds.Select(id => current[id]).ToList(), added, removed); } - - } public class CloudFileUnusedRecyclingJob(AppDatabase db, FileService fs, ILogger logger) diff --git a/DysonNetwork.Sphere/appsettings.json b/DysonNetwork.Sphere/appsettings.json index 3660cc0..d817ffc 100644 --- a/DysonNetwork.Sphere/appsettings.json +++ b/DysonNetwork.Sphere/appsettings.json @@ -9,7 +9,8 @@ }, "AllowedHosts": "*", "ConnectionStrings": { - "App": "Host=localhost;Port=5432;Database=dyson_network;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60" + "App": "Host=localhost;Port=5432;Database=dyson_network;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60", + "FastRetrieve": "localhost:6379" }, "Authentication": { "Schemes": {