From 460ce6245284c27bf1dee2660f578050588a3efa Mon Sep 17 00:00:00 2001
From: LittleSheep <littlesheep.code@hotmail.com>
Date: Sat, 24 May 2025 17:29:24 +0800
Subject: [PATCH] :recycle: Refactor cache system with redis :bug: Add lock to
 check in prevent multiple at the same time

---
 .../Account/AccountController.cs              |   2 +-
 .../Account/AccountEventService.cs            |  32 +-
 DysonNetwork.Sphere/Account/AccountService.cs |  14 +-
 .../Account/RelationshipService.cs            |  32 +-
 .../Auth/UserInfoMiddleware.cs                |  15 +-
 DysonNetwork.Sphere/Chat/ChatRoomService.cs   |  29 +-
 .../Connection/Handlers/MessageReadHandler.cs |  15 +-
 .../Handlers/MessageTypingHandler.cs          |  16 +-
 .../DysonNetwork.Sphere.csproj                |   2 +
 .../Permission/PermissionService.cs           |  64 ++--
 DysonNetwork.Sphere/Program.cs                |   8 +
 .../Publisher/PublisherService.cs             |  22 +-
 DysonNetwork.Sphere/Sticker/StickerService.cs |  52 +--
 DysonNetwork.Sphere/Storage/CacheService.cs   | 356 ++++++++++++++++++
 DysonNetwork.Sphere/Storage/FileService.cs    |  68 ++--
 DysonNetwork.Sphere/appsettings.json          |   3 +-
 16 files changed, 569 insertions(+), 161 deletions(-)
 create mode 100644 DysonNetwork.Sphere/Storage/CacheService.cs

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<Localization.AccountEventResource> 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<Status> GetStatus(Guid userId)
     {
         var cacheKey = $"{StatusCacheKey}{userId}";
-        if (cache.TryGetValue(cacheKey, out Status? cachedStatus))
+        var cachedStatus = await cache.GetAsync<Status>(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<bool> CheckInDailyDoAskCaptcha(Account user)
     {
         var cacheKey = $"{CaptchaCacheKey}{user.Id}";
-        if (cache.TryGetValue(cacheKey, out bool? needsCaptcha))
+        var needsCaptcha = await cache.GetAsync<bool?>(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<CheckInResult> 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<Account?> 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<bool> 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<Relationship> 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<List<Guid>> ListAccountFriends(Account account)
     {
-        if (!cache.TryGetValue($"UserFriends_{account.Id}", out List<Guid>? friends))
+        string cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}";
+        var friends = await cache.GetAsync<List<Guid>>(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<Session>($"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<List<ChatMember>> ListRoomMembers(Guid roomId)
     {
-        var cacheKey = string.Format(RoomMembersCacheKey, roomId);
-        if (cache.TryGetValue(cacheKey, out List<ChatMember>? cachedMembers))
-            return cachedMembers!;
+        var cacheKey = RoomMembersCacheKeyPrefix + roomId;
+        var cachedMembers = await cache.GetAsync<List<ChatMember>>(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<List<ChatRoom>> SortChatRoomByLastMessage(List<ChatRoom> 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<ChatMember?>(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<ChatMember?>(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 @@
         <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" />
         <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" />
         <PackageReference Include="SkiaSharp.NativeAssets.macOS" Version="2.88.9" />
+        <PackageReference Include="StackExchange.Redis" Version="2.8.37" />
+        <PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="11.0.0" />
         <PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.0"/>
         <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="8.1.0"/>
         <PackageReference Include="tusdotnet" Version="2.8.1"/>
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<PermissionService> 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<bool> HasPermissionAsync(string actor, string area, string key)
     {
@@ -26,26 +33,31 @@ public class PermissionService(
 
     public async Task<T?> GetPermissionAsync<T>(string actor, string area, string key)
     {
-        var cacheKey = GetPermissionCacheKey(actor, area, key);
+        var cacheKey = _GetPermissionCacheKey(actor, area, key);
         
-        if (cache.TryGetValue<T>(cacheKey, out var cachedValue))
+        var cachedValue = await cache.GetAsync<T>(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<List<Guid>>(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<T>(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<T>(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<T>(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<AppDatabase>();
+builder.Services.AddSingleton<IConnectionMultiplexer>(sp => 
+{
+    var connection = builder.Configuration.GetConnectionString("FastRetrieve")!;
+    return ConnectionMultiplexer.Connect(connection);
+});
+
+builder.Services.AddScoped<ICacheService, CacheServiceRedis>();
 
 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<Publisher> CreateIndividualPublisher(
         Account.Account account,
@@ -101,7 +101,8 @@ public class PublisherService(AppDatabase db, FileService fs, IMemoryCache cache
     public async Task<PublisherStats?> GetPublisherStats(string name)
     {
         var cacheKey = string.Format(PublisherStatsCacheKey, name);
-        if (cache.TryGetValue(cacheKey, out PublisherStats? stats))
+        var stats = await cache.GetAsync<PublisherStats>(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<bool> 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<bool?>(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<bool> 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<Sticker> CreateStickerAsync(Sticker sticker)
     {
         db.Stickers.Add(sticker);
         await db.SaveChangesAsync();
-        
+
         await fs.MarkUsageAsync(sticker.Image, 1);
 
         return sticker;
     }
+
     public async Task<Sticker> 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<Sticker?> 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<Sticker>(cacheKey);
+        if (cachedSticker is not null)
             return cachedSticker;
-        }
-        
+
         // If not in cache, fetch from the database
         IQueryable<Sticker> 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;
+
+/// <summary>
+/// Represents a distributed lock that can be used to synchronize access across multiple processes
+/// </summary>
+public interface IDistributedLock : IAsyncDisposable
+{
+    /// <summary>
+    /// The resource identifier this lock is protecting
+    /// </summary>
+    string Resource { get; }
+    
+    /// <summary>
+    /// Unique identifier for this lock instance
+    /// </summary>
+    string LockId { get; }
+    
+    /// <summary>
+    /// Extends the lock's expiration time
+    /// </summary>
+    Task<bool> ExtendAsync(TimeSpan timeSpan);
+    
+    /// <summary>
+    /// Releases the lock immediately
+    /// </summary>
+    Task ReleaseAsync();
+}
+
+public interface ICacheService
+{
+    /// <summary>
+    /// Sets a value in the cache with an optional expiration time
+    /// </summary>
+    Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null);
+    
+    /// <summary>
+    /// Gets a value from the cache
+    /// </summary>
+    Task<T?> GetAsync<T>(string key);
+    
+    /// <summary>
+    /// Removes a specific key from the cache
+    /// </summary>
+    Task<bool> RemoveAsync(string key);
+    
+    /// <summary>
+    /// Adds a key to a group for group-based operations
+    /// </summary>
+    Task AddToGroupAsync(string key, string group);
+    
+    /// <summary>
+    /// Removes all keys associated with a specific group
+    /// </summary>
+    Task RemoveGroupAsync(string group);
+    
+    /// <summary>
+    /// Gets all keys belonging to a specific group
+    /// </summary>
+    Task<IEnumerable<string>> GetGroupKeysAsync(string group);
+    
+    /// <summary>
+    /// Helper method to set a value in cache and associate it with multiple groups in one operation
+    /// </summary>
+    /// <typeparam name="T">The type of value being cached</typeparam>
+    /// <param name="key">Cache key</param>
+    /// <param name="value">The value to cache</param>
+    /// <param name="groups">Optional collection of group names to associate the key with</param>
+    /// <param name="expiry">Optional expiration time for the cached item</param>
+    /// <returns>True if the set operation was successful</returns>
+    Task<bool> SetWithGroupsAsync<T>(string key, T value, IEnumerable<string>? groups = null, TimeSpan? expiry = null);
+    
+    /// <summary>
+    /// Acquires a distributed lock on the specified resource
+    /// </summary>
+    /// <param name="resource">The resource identifier to lock</param>
+    /// <param name="expiry">How long the lock should be held before automatically expiring</param>
+    /// <param name="waitTime">How long to wait for the lock before giving up</param>
+    /// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
+    /// <returns>A distributed lock instance if acquired, null otherwise</returns>
+    Task<IDistributedLock?> AcquireLockAsync(string resource, TimeSpan expiry, TimeSpan? waitTime = null, TimeSpan? retryInterval = null);
+    
+    /// <summary>
+    /// Executes an action with a distributed lock, ensuring the lock is properly released afterwards
+    /// </summary>
+    /// <param name="resource">The resource identifier to lock</param>
+    /// <param name="action">The action to execute while holding the lock</param>
+    /// <param name="expiry">How long the lock should be held before automatically expiring</param>
+    /// <param name="waitTime">How long to wait for the lock before giving up</param>
+    /// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
+    /// <returns>True if the lock was acquired and the action was executed, false otherwise</returns>
+    Task<bool> ExecuteWithLockAsync(string resource, Func<Task> action, TimeSpan expiry, TimeSpan? waitTime = null, TimeSpan? retryInterval = null);
+    
+    /// <summary>
+    /// Executes a function with a distributed lock, ensuring the lock is properly released afterwards
+    /// </summary>
+    /// <typeparam name="T">The return type of the function</typeparam>
+    /// <param name="resource">The resource identifier to lock</param>
+    /// <param name="func">The function to execute while holding the lock</param>
+    /// <param name="expiry">How long the lock should be held before automatically expiring</param>
+    /// <param name="waitTime">How long to wait for the lock before giving up</param>
+    /// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
+    /// <returns>The result of the function if the lock was acquired, default(T) otherwise</returns>
+    Task<(bool Acquired, T? Result)> ExecuteWithLockAsync<T>(string resource, Func<Task<T>> 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<bool> 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<bool> SetAsync<T>(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<T?> GetAsync<T>(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<T>(value!, _serializerOptions);
+    }
+    
+    public async Task<bool> 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<IEnumerable<string>> 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<bool> SetWithGroupsAsync<T>(string key, T value, IEnumerable<string>? 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<IDistributedLock?> 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<bool> ExecuteWithLockAsync(string resource, Func<Task> 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<T>(string resource, Func<Task<T>> 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<FileService> logger,
     IServiceScopeFactory scopeFactory,
-    IMemoryCache cache
+    ICacheService cache
 )
 {
     private const string CacheKeyPrefix = "cloudfile_";
     private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15);
-    
+
     /// <summary>
     /// 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<CloudFile?> GetFileAsync(string fileId)
     {
         var cacheKey = $"{CacheKeyPrefix}{fileId}";
-        
-        if (cache.TryGetValue(cacheKey, out CloudFile? cachedFile))
+
+        var cachedFile = await cache.GetAsync<CloudFile>(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<CloudFile> 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<CloudFile> current, ICollection<CloudFile> added, ICollection<CloudFile> removed)> DiffAndMarkFilesAsync(
-        ICollection<string>? newFileIds,
-        ICollection<CloudFile>? previousFiles = null
-    )
+
+    public async Task<(ICollection<CloudFile> current, ICollection<CloudFile> added, ICollection<CloudFile> removed)>
+        DiffAndMarkFilesAsync(
+            ICollection<string>? newFileIds,
+            ICollection<CloudFile>? 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<string, CloudFile>();
         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<CloudFile> current, ICollection<CloudFile> added, ICollection<CloudFile> removed)> DiffAndSetExpiresAsync(
-        ICollection<string>? newFileIds,
-        Duration? duration,
-        ICollection<CloudFile>? previousFiles = null
-    )
+
+    public async Task<(ICollection<CloudFile> current, ICollection<CloudFile> added, ICollection<CloudFile> removed)>
+        DiffAndSetExpiresAsync(
+            ICollection<string>? newFileIds,
+            Duration? duration,
+            ICollection<CloudFile>? 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<string, CloudFile>();
         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<CloudFileUnusedRecyclingJob> 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": {