using adas_core.Application.Services.Interfaces; using adas_core.Domain.Models.AppSettings; using adas_core.Domain.Utils; using adas_core.Domain.Enums; using adas_core.Domain.Models.GroupedObservations; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Newtonsoft.Json; using MongoDB.Bson; using Newtonsoft.Json.Converters; using Newtonsoft.Json.Serialization; using StackExchange.Redis; namespace adas_core.Application.Services.Caching { public class RedisService : ICacheService { private readonly ILogger _logger; private readonly CacheSettings _cacheSettings; private readonly LockManagerService _lockManager; private ConnectionMultiplexer? _connection; private IDatabase? _database; private IServer? _server; public IDatabase? Database => _database; private bool _isRedisAvailable; public RedisService( IOptions options, ILogger logger, LockManagerService lockManager) { _cacheSettings = options.Value; _logger = logger; _lockManager = lockManager; if (!string.IsNullOrEmpty(_cacheSettings.Redis.ConnectionString)) _ = InitializeRedisConnectionAsync(); } // GET OR SET (string key) public async Task GetOrSetObjectAsync( string key, Func> factory, TimeSpan? ttl = null, CancellationToken cancellationToken = default) { if (!_isRedisAvailable) return await factory(); var direct = await GetObjectAsync(key); if (direct is not null) return direct; return await _lockManager.WithLockAsync( $"getorset:{key}", TimeSpan.FromSeconds(5), async () => { var again = await GetObjectAsync(key); if (again is not null) return again; var created = await factory(); if (created != null) await SetObjectAsync(key, created, ttl, true); return created!; }, cancellationToken); } public async Task GetOrSetValueAsync( string key, Func> loader, TimeSpan? ttl = null) { if (!_isRedisAvailable) return await loader(); var direct = GetValue(key); if (direct is not null) return direct; return await _lockManager.WithLockAsync( $"getorset:{key}", TimeSpan.FromSeconds(5), async () => { var again = GetValue(key); if (again is not null) return again; var created = await loader(); SetValue(key, created); return created; }); } // GET OR SET (GroupedField + patientId) private static string BuildGroupedKey(GroupedField gf, ObjectId patientId) => $"GroupedObs:{patientId}:{gf.Name}"; public async Task GetOrSetObjectAsync( GroupedField groupedField, ObjectId patientId, Func> factory, TimeSpan? ttl = null, CancellationToken cancellationToken = default) { var key = BuildGroupedKey(groupedField, patientId); if (!_isRedisAvailable) return await factory(); var direct = await GetObjectAsync(key); if (direct is not null) return direct; return await _lockManager.WithLockAsync( $"getorset:{key}", TimeSpan.FromSeconds(5), async () => { var again = await GetObjectAsync(key); if (again is not null) return again; var created = await factory(); if (created != null) await SetObjectAsync(key, created, ttl, true); return created!; }, cancellationToken); } // BASIC OPERATIONS public void SetValue(string key, string value) => _database?.StringSet(key, value, GetEntityTtl(key), true); public string? GetValue(string key) { var val = _database?.StringGet(key); if (val.HasValue && ShouldRenewTtl(key)) _database?.KeyExpire(key, GetEntityTtl(key)); return val; } public async Task GetObjectAsync(string key, bool updateExpiration = true) { if (!_isRedisAvailable) return default; var json = await _database!.StringGetAsync(key); if (json.IsNullOrEmpty) return default; if (updateExpiration) _database!.KeyExpire(key, GetEntityTtl(key)); var settings = new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver(), Converters = new List { new StringEnumConverter(), new ObjectIdConverter() } }; try { return JsonConvert.DeserializeObject(json!, settings); } catch (Exception e) { _logger.LogError("Error deserializing object in RedisService {e}",e.ToString()); throw new Exception($"Error deserializing object in RedisService {e}", e); } } public async Task SetObjectAsync( string key, T obj, bool updateExpiration = true) => await SetObjectAsync(key, obj, null, updateExpiration); public async Task SetObjectAsync( string key, T obj, TimeSpan? ttlOverride, bool updateExpiration) { if (!_isRedisAvailable) return; var settings = new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver(), Converters = new List { new StringEnumConverter(), new ObjectIdConverter() } }; var json = JsonConvert.SerializeObject(obj, settings); await _database!.StringSetAsync(key, json, ttlOverride ?? GetEntityTtl(key), true); } public async Task DeleteObjectAsync(string key) { if (_isRedisAvailable) await _database!.KeyDeleteAsync(key); } public async Task DeleteByPatternAsync(string pattern) { if (!_isRedisAvailable || _server == null) return 0; var keys = _server.Keys(pattern: pattern).ToArray(); foreach (var key in keys) await _database!.KeyDeleteAsync(key); return keys.Length; } public void CleanCache() => _server?.FlushDatabase(); // TTL private TimeSpan? GetEntityTtl(string key) { var entity = CacheKeyClassifier.Classify(key); var ttl = _cacheSettings.Redis.Ttl; int? seconds = entity switch { CacheEnum.EntityType.Patients => ttl.PatientsSeconds, CacheEnum.EntityType.Displays => ttl.DisplaysSeconds, _ => ttl.GlobalSeconds }; return seconds > 0 ? TimeSpan.FromSeconds(seconds.Value) : null; } private bool ShouldRenewTtl(string key) => GetEntityTtl(key) != null; // INITIALIZATION private async Task InitializeRedisConnectionAsync() { _isRedisAvailable = false; try { if (_cacheSettings.Redis.ConnectionString == null) return; _connection = await ConnectionMultiplexer.ConnectAsync(_cacheSettings.Redis.ConnectionString); _database = _connection.GetDatabase(); _server = _connection.GetServer(_cacheSettings.Redis.ConnectionString); _isRedisAvailable = true; } catch(Exception ex) { _logger.LogError( "Error Initializing Redis Connection. Exception:{ex}", ex.Message); } } public Task GetObjectAsync(string key, TimeSpan? ttlOverride, bool updateExpiration) => GetObjectAsync(key, updateExpiration); } }