diff --git a/DysonNetwork.Sphere/Connection/Handlers/MessageTypingHandler.cs b/DysonNetwork.Sphere/Connection/Handlers/MessageTypingHandler.cs index 7bc43b8..11e4dd9 100644 --- a/DysonNetwork.Sphere/Connection/Handlers/MessageTypingHandler.cs +++ b/DysonNetwork.Sphere/Connection/Handlers/MessageTypingHandler.cs @@ -2,7 +2,6 @@ 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; diff --git a/DysonNetwork.Sphere/Connection/WebSocketPacket.cs b/DysonNetwork.Sphere/Connection/WebSocketPacket.cs index 09d6f68..1028311 100644 --- a/DysonNetwork.Sphere/Connection/WebSocketPacket.cs +++ b/DysonNetwork.Sphere/Connection/WebSocketPacket.cs @@ -61,7 +61,7 @@ public class WebSocketPacket { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower, - }.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);; + }.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); var json = JsonSerializer.Serialize(this, jsonOpts); return System.Text.Encoding.UTF8.GetBytes(json); } diff --git a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj index 6833c32..4d2effa 100644 --- a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj +++ b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj @@ -40,7 +40,9 @@ + + diff --git a/DysonNetwork.Sphere/Permission/PermissionService.cs b/DysonNetwork.Sphere/Permission/PermissionService.cs index 68c4424..45ba123 100644 --- a/DysonNetwork.Sphere/Permission/PermissionService.cs +++ b/DysonNetwork.Sphere/Permission/PermissionService.cs @@ -11,17 +11,17 @@ public class PermissionService( 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 static string _GetPermissionCacheKey(string actor, string area, string key) => + private static string _GetPermissionCacheKey(string actor, string area, string key) => PermCacheKeyPrefix + actor + ":" + area + ":" + key; - private static string _GetGroupsCacheKey(string actor) => + private static string _GetGroupsCacheKey(string actor) => PermGroupCacheKeyPrefix + actor; - + private static string _GetPermissionGroupKey(string actor) => PermissionGroupPrefix + actor; @@ -68,8 +68,8 @@ public class PermissionService( var result = permission is not null ? _DeserializePermissionValue(permission.Value) : default; - await cache.SetWithGroupsAsync(cacheKey, result, - new[] { _GetPermissionGroupKey(actor) }, + await cache.SetWithGroupsAsync(cacheKey, result, + [_GetPermissionGroupKey(actor)], CacheExpiration); return result; diff --git a/DysonNetwork.Sphere/Program.cs b/DysonNetwork.Sphere/Program.cs index a96327f..4c16c3c 100644 --- a/DysonNetwork.Sphere/Program.cs +++ b/DysonNetwork.Sphere/Program.cs @@ -45,13 +45,12 @@ builder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize = builder.Services.AddLocalization(options => options.ResourcesPath = "Resources"); builder.Services.AddDbContext(); -builder.Services.AddSingleton(sp => +builder.Services.AddSingleton(_ => { var connection = builder.Configuration.GetConnectionString("FastRetrieve")!; return ConnectionMultiplexer.Connect(connection); }); - -builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddHttpClient(); builder.Services.AddControllers().AddJsonOptions(options => diff --git a/DysonNetwork.Sphere/Sticker/StickerService.cs b/DysonNetwork.Sphere/Sticker/StickerService.cs index 82491c8..abae50f 100644 --- a/DysonNetwork.Sphere/Sticker/StickerService.cs +++ b/DysonNetwork.Sphere/Sticker/StickerService.cs @@ -67,9 +67,7 @@ public class StickerService(AppDatabase db, FileService fs, ICacheService cache) // Invalidate cache for all stickers in this pack foreach (var sticker in stickers) - { - PurgeStickerCache(sticker); - } + await PurgeStickerCache(sticker); } public async Task LookupStickerByIdentifierAsync(string identifier) diff --git a/DysonNetwork.Sphere/Storage/CacheService.cs b/DysonNetwork.Sphere/Storage/CacheService.cs index 61b0cfc..7a81cf1 100644 --- a/DysonNetwork.Sphere/Storage/CacheService.cs +++ b/DysonNetwork.Sphere/Storage/CacheService.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using NodaTime; +using NodaTime.Serialization.JsonNet; using StackExchange.Redis; namespace DysonNetwork.Sphere.Storage; @@ -12,17 +15,17 @@ 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 /// @@ -35,32 +38,32 @@ 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 /// @@ -71,7 +74,7 @@ public interface ICacheService /// 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 /// @@ -80,8 +83,9 @@ public interface ICacheService /// 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); - + 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 /// @@ -91,8 +95,9 @@ public interface ICacheService /// 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); - + 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 /// @@ -103,31 +108,32 @@ public interface ICacheService /// 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); + 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]) @@ -135,21 +141,21 @@ public class RedisDistributedLock : IDistributedLock return 0 end "; - + var result = await _database.ScriptEvaluateAsync( script, - new RedisKey[] { $"{LockKeyPrefix}{Resource}" }, - new RedisValue[] { LockId, (long)timeSpan.TotalMilliseconds } + [$"{LockKeyPrefix}{Resource}"], + [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]) @@ -157,16 +163,16 @@ public class RedisDistributedLock : IDistributedLock return 0 end "; - + await _database.ScriptEvaluateAsync( script, - new RedisKey[] { $"{LockKeyPrefix}{Resource}" }, - new RedisValue[] { LockId } + [$"{LockKeyPrefix}{Resource}"], + [LockId] ); - + _disposed = true; } - + public async ValueTask DisposeAsync() { await ReleaseAsync(); @@ -177,49 +183,54 @@ public class RedisDistributedLock : IDistributedLock public class CacheServiceRedis : ICacheService { private readonly IDatabase _database; - private readonly JsonSerializerOptions _serializerOptions; - + private readonly JsonSerializerSettings _serializerSettings; + + // Global prefix for all cache keys + private const string GlobalKeyPrefix = "dyson:"; + // Using prefixes for different types of keys - private const string GroupKeyPrefix = "CacheGroup_"; - private const string LockKeyPrefix = "Lock_"; - + private const string GroupKeyPrefix = GlobalKeyPrefix + "cg:"; + private const string LockKeyPrefix = GlobalKeyPrefix + "lock:"; + public CacheServiceRedis(IConnectionMultiplexer redis) { var rds = redis ?? throw new ArgumentNullException(nameof(redis)); _database = rds.GetDatabase(); - _serializerOptions = new JsonSerializerOptions + _serializerSettings = new JsonSerializerSettings { - PropertyNameCaseInsensitive = true - }; + ContractResolver = new CamelCasePropertyNamesContractResolver(), + PreserveReferencesHandling = PreserveReferencesHandling.Objects, + NullValueHandling = NullValueHandling.Include, + }.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); } - + 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); + + var serializedValue = JsonConvert.SerializeObject(value, _serializerSettings); 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); + + return JsonConvert.DeserializeObject(value!, _serializerSettings); } - + 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]) @@ -228,128 +239,128 @@ public class CacheServiceRedis : ICacheService end return redis.call('DEL', ARGV[2]) "; - + var result = await _database.ScriptEvaluateAsync( script, - values: new RedisValue[] { $"{GroupKeyPrefix}*", key } + values: [$"{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) + + public async Task SetWithGroupsAsync(string key, T value, IEnumerable? groups = null, + TimeSpan? expiry = null) { - // First set the value in the cache + // 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); - } - } - + if (!setResult || groups == null) return setResult; + var groupsArray = groups.Where(g => !string.IsNullOrEmpty(g)).ToArray(); + if (groupsArray.Length <= 0) return setResult; + 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) + + 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) + + 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) + + 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); }