376 lines
20 KiB
C#
376 lines
20 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// Represents a Redis-based implementation of the <see cref="ICacheService"/> interface for caching operations.
|
|
/// </summary>
|
|
public class RedisService : ICacheService
|
|
{
|
|
private readonly ILogger<RedisService> _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<CacheSettings> options,
|
|
ILogger<RedisService> logger,
|
|
LockManagerService lockManager)
|
|
{
|
|
_cacheSettings = options.Value;
|
|
_logger = logger;
|
|
_lockManager = lockManager;
|
|
|
|
if (!string.IsNullOrEmpty(_cacheSettings.Redis.ConnectionString))
|
|
_ = InitializeRedisConnectionAsync();
|
|
}
|
|
|
|
|
|
// GET OR SET (string key)
|
|
/// <summary>
|
|
/// Retrieves an object of type T from the cache using the specified key, or creates and caches a new instance using the provided factory if no cached value exists.
|
|
/// Uses a distributed lock to prevent concurrent cache misses from creating duplicate objects, and falls back to calling the factory directly when Redis is unavailable.
|
|
/// </summary>
|
|
/// <param name="key">The cache key used to identify the stored object.</param>
|
|
/// <param name="factory">The asynchronous factory function invoked to create a new instance when the object is not present in the cache.</param>
|
|
/// <param name="ttl">Optional time-to-live duration for the cached object. If null, the cache default is used.</param>
|
|
/// <param name="cancellationToken">The token used to cancel the asynchronous operation.</param>
|
|
/// <returns>A task that represents the asynchronous operation, containing the cached or newly created object.</returns>
|
|
public async Task<T> GetOrSetObjectAsync<T>(
|
|
string key,
|
|
Func<Task<T>> factory,
|
|
TimeSpan? ttl = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!_isRedisAvailable)
|
|
return await factory();
|
|
|
|
var direct = await GetObjectAsync<T>(key);
|
|
|
|
if (direct is not null)
|
|
return direct;
|
|
|
|
return await _lockManager.WithLockAsync(
|
|
$"getorset:{key}",
|
|
TimeSpan.FromSeconds(5),
|
|
async () =>
|
|
{
|
|
var again = await GetObjectAsync<T>(key);
|
|
if (again is not null)
|
|
return again;
|
|
|
|
var created = await factory();
|
|
|
|
if (created != null)
|
|
await SetObjectAsync(key, created, ttl, true);
|
|
|
|
return created!;
|
|
},
|
|
cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves a cached value for the specified key, or loads, caches, and returns it via the supplied loader if absent.
|
|
/// Falls back to invoking the loader directly when Redis is unavailable, and uses a distributed lock to prevent duplicate loads under concurrent access.
|
|
/// </summary>
|
|
/// <param name="key">The cache key used to identify the stored value.</param>
|
|
/// <param name="loader">The asynchronous function invoked to produce the value when it is not present in the cache.</param>
|
|
/// <param name="ttl">Optional time-to-live applied to the cached value; if not provided, the default caching policy is used.</param>
|
|
/// <returns>The cached value when available, or the value produced by the loader when the cache is empty or Redis is unavailable.</returns>
|
|
public async Task<string?> GetOrSetValueAsync(
|
|
string key,
|
|
Func<Task<string>> 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)
|
|
/// <summary>
|
|
/// Builds a unique cache key for a grouped observation associated with a specific patient.
|
|
/// </summary>
|
|
/// <param name="gf">The grouped field whose name is used to identify the observation group.</param>
|
|
/// <param name="patientId">The identifier of the patient the observation belongs to.</param>
|
|
/// <returns>A formatted string key combining the <c>GroupedObs</c> prefix, the patient identifier, and the grouped field name.</returns>
|
|
private static string BuildGroupedKey(GroupedField gf, ObjectId patientId)
|
|
=> $"GroupedObs:{patientId}:{gf.Name}";
|
|
|
|
/// <summary>
|
|
/// Retrieves a cached object associated with the specified grouped field and patient identifier, or creates and stores it using the provided factory if absent. Uses a distributed lock to prevent duplicate creation under cache misses and falls back to invoking the factory directly when Redis is unavailable.
|
|
/// </summary>
|
|
/// <param name="groupedField">The grouped field used, together with the patient identifier, to build the cache key.</param>
|
|
/// <param name="patientId">The patient identifier used to build the cache key.</param>
|
|
/// <param name="factory">Asynchronous factory invoked to produce the object when no cached value exists.</param>
|
|
/// <param name="ttl">Optional time-to-live applied to the stored cache entry. If null, no expiration is set.</param>
|
|
/// <param name="cancellationToken">Token used to cancel the distributed lock operation.</param>
|
|
/// <returns>The cached object if present, otherwise the object produced by <paramref name="factory"/>.</returns>
|
|
public async Task<T> GetOrSetObjectAsync<T>(
|
|
GroupedField groupedField,
|
|
ObjectId patientId,
|
|
Func<Task<T>> factory,
|
|
TimeSpan? ttl = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var key = BuildGroupedKey(groupedField, patientId);
|
|
|
|
if (!_isRedisAvailable)
|
|
return await factory();
|
|
|
|
var direct = await GetObjectAsync<T>(key);
|
|
if (direct is not null)
|
|
return direct;
|
|
|
|
return await _lockManager.WithLockAsync(
|
|
$"getorset:{key}",
|
|
TimeSpan.FromSeconds(5),
|
|
async () =>
|
|
{
|
|
var again = await GetObjectAsync<T>(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
|
|
/// <summary>
|
|
/// Stores a string value in the database under the specified key, applying a TTL resolved from <c>GetEntityTtl</c> and preserving any existing TTL on overwrite. If the underlying database is not initialized, the operation is skipped.
|
|
/// </summary>
|
|
/// <param name="key">The key under which the value will be stored.</param>
|
|
/// <param name="value">The string value to persist.</param>
|
|
public void SetValue(string key, string value)
|
|
=> _database?.StringSet(key, value, GetEntityTtl(key), true);
|
|
|
|
/// <summary>
|
|
/// Retrieves a string value from the underlying data store by its key, and conditionally renews the entity's time-to-live when the key is found and renewal is permitted by policy.
|
|
/// </summary>
|
|
/// <param name="key">The identifier of the value to look up in the data store.</param>
|
|
/// <returns>The stored string value, or <c>null</c> if the key does not exist or the data store is unavailable.</returns>
|
|
public string? GetValue(string key)
|
|
{
|
|
var val = _database?.StringGet(key);
|
|
if (val.HasValue && ShouldRenewTtl(key))
|
|
_database?.KeyExpire(key, GetEntityTtl(key));
|
|
return val;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Asynchronously retrieves and deserializes an object of type <typeparamref name="T"/> from Redis using the specified key.
|
|
/// Returns <c>default</c> when Redis is unavailable or when the key is not found or holds an empty value, and optionally refreshes the key's expiration time on a successful hit.
|
|
/// </summary>
|
|
/// <param name="key">The Redis key identifying the stored object to retrieve.</param>
|
|
/// <param name="updateExpiration">When <c>true</c> (the default), resets the key's time-to-live to the configured entity TTL on a successful read, implementing sliding expiration.</param>
|
|
/// <returns>A <see cref="Task{T}"/> containing the deserialized object, or <c>default</c> if Redis is unavailable or the key is missing/empty.</returns>
|
|
/// <exception cref="Exception">Thrown when the stored JSON payload cannot be deserialized into <typeparamref name="T"/>; the original exception is wrapped and rethrown.</exception>
|
|
public async Task<T?> GetObjectAsync<T>(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<JsonConverter> { new StringEnumConverter(), new ObjectIdConverter() }
|
|
};
|
|
try
|
|
{
|
|
return JsonConvert.DeserializeObject<T>(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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Asynchronously stores an object associated with the specified key, optionally refreshing its expiration time.
|
|
/// </summary>
|
|
/// <param name="key">The key under which the object will be stored.</param>
|
|
/// <param name="obj">The object to store.</param>
|
|
/// <param name="updateExpiration">Indicates whether the expiration time of the entry should be updated.</param>
|
|
public async Task SetObjectAsync<T>(
|
|
string key,
|
|
T obj,
|
|
bool updateExpiration = true)
|
|
=> await SetObjectAsync(key, obj, null, updateExpiration);
|
|
|
|
/// <summary>
|
|
/// Asynchronously serializes the specified object to JSON and stores it in Redis under the given key, using the provided TTL override or the default entity TTL when not specified. The operation is skipped when Redis is unavailable, and the object is serialized using camelCase property names with string enum and ObjectId converters.
|
|
/// </summary>
|
|
/// <param name="key">The Redis key under which the serialized object will be stored.</param>
|
|
/// <param name="obj">The object to serialize and persist to Redis.</param>
|
|
/// <param name="ttlOverride">An optional time-to-live override; when null, the entity's default TTL is applied.</param>
|
|
/// <param name="updateExpiration">Flag indicating whether the expiration should be updated.</param>
|
|
public async Task SetObjectAsync<T>(
|
|
string key,
|
|
T obj,
|
|
TimeSpan? ttlOverride,
|
|
bool updateExpiration)
|
|
{
|
|
if (!_isRedisAvailable)
|
|
return;
|
|
var settings = new JsonSerializerSettings
|
|
{
|
|
ContractResolver = new CamelCasePropertyNamesContractResolver(),
|
|
Converters = new List<JsonConverter> { new StringEnumConverter(), new ObjectIdConverter() }
|
|
};
|
|
var json = JsonConvert.SerializeObject(obj, settings);
|
|
await _database!.StringSetAsync(key, json, ttlOverride ?? GetEntityTtl(key), true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Asynchronously deletes an object from the Redis cache using the specified key.
|
|
/// When the Redis backend is unavailable, the call is skipped silently as a no-op fallback.
|
|
/// </summary>
|
|
/// <param name="key">The unique identifier of the cached object to remove.</param>
|
|
public async Task DeleteObjectAsync(string key)
|
|
{
|
|
if (_isRedisAvailable)
|
|
await _database!.KeyDeleteAsync(key);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Asynchronously deletes all Redis keys matching the specified pattern.
|
|
/// Returns 0 if Redis is unavailable or the server is not initialized.
|
|
/// </summary>
|
|
/// <param name="pattern">The pattern used to match Redis keys to be deleted.</param>
|
|
/// <returns>The number of keys that were deleted.</returns>
|
|
public async Task<long> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears all cached data by flushing the underlying server database. If the server instance is <see langword="null"/>, the call is safely skipped as a no-op.
|
|
/// </summary>
|
|
public void CleanCache()
|
|
=> _server?.FlushDatabase();
|
|
|
|
|
|
// TTL
|
|
/// <summary>
|
|
/// Resolves the time-to-live (TTL) for a cache entity based on the entity type inferred from the cache key, returning entity-specific TTL values for Patients and Displays while falling back to the global TTL for any other entity. Returns <c>null</c> when the resolved TTL in seconds is zero or negative, indicating that the entity should not be cached.
|
|
/// </summary>
|
|
/// <param name="key">The cache key used to classify the entity type and determine the applicable TTL.</param>
|
|
/// <returns>A <see cref="TimeSpan"/> representing the configured TTL, or <c>null</c> if the resolved seconds value is not positive.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines whether the time-to-live (TTL) for the entity associated with the specified key should be renewed, based on whether an existing TTL value is found.
|
|
/// </summary>
|
|
/// <param name="key">The key identifying the entity whose TTL presence is being checked.</param>
|
|
/// <returns><c>true</c> if a TTL value is found for the specified key; otherwise, <c>false</c>.</returns>
|
|
private bool ShouldRenewTtl(string key)
|
|
=> GetEntityTtl(key) != null;
|
|
|
|
|
|
// INITIALIZATION
|
|
/// <summary>
|
|
/// Initializes the Redis connection for caching by connecting asynchronously, obtaining the database and server, and marking the connection as available on success. Returns early without establishing a connection when the configured Redis connection string is null, and logs any exception that occurs during initialization without rethrowing, leaving the connection marked as unavailable.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves an object asynchronously from the cache, optionally updating its expiration time.
|
|
/// The optional TTL override is ignored by this overload and is not passed to the underlying call.
|
|
/// </summary>
|
|
/// <param name="key">The cache key identifying the object to retrieve.</param>
|
|
/// <param name="ttlOverride">An optional time-to-live override; not applied by this overload.</param>
|
|
/// <param name="updateExpiration">When true, the expiration of the cached entry is refreshed on retrieval.</param>
|
|
/// <returns>A task that resolves to the cached object, or null if no entry exists for the specified key.</returns>
|
|
public Task<T?> GetObjectAsync<T>(string key, TimeSpan? ttlOverride, bool updateExpiration)
|
|
=> GetObjectAsync<T>(key, updateExpiration);
|
|
}
|
|
} |