rama creada apartir de master en j

This commit is contained in:
jrojas
2026-06-26 10:29:23 +02:00
parent 319fd3dfb0
commit c1517fda87
2810 changed files with 1927392 additions and 25392 deletions
@@ -21,91 +21,189 @@ namespace adas_core.Application.Services.Caching
{
// Selección de backend
/// <summary>
/// Selects the appropriate cache backend (Redis, in-memory, or no-op) for the given key by classifying the key into an entity type and resolving its configured cache mode.
/// Unknown entity types default to in-memory caching, and unrecognized modes fall back to the no-op cache service.
/// </summary>
/// <param name="key">The cache key used to determine the entity type and the corresponding cache backend.</param>
/// <returns>The <see cref="ICacheService"/> instance that should handle caching for the supplied key.</returns>
private ICacheService SelectBackend(string key)
{
var entity = CacheKeyClassifier.Classify(key);
var mode = entity switch
{
CacheEnum.EntityType.Patients => cacheSettings.Patients,
CacheEnum.EntityType.Displays => cacheSettings.Displays,
CacheEnum.EntityType.PumpObservations => cacheSettings.PumpObservations,
CacheEnum.EntityType.Appointments => cacheSettings.Appointments,
CacheEnum.EntityType.GroupedObservations => cacheSettings.GroupedObservations,
_ => CacheEnum.Mode.Cache
};
return mode switch
{
CacheEnum.Mode.Redis => redis,
CacheEnum.Mode.Cache => memory,
_ => noop
};
}
{
var entity = CacheKeyClassifier.Classify(key);
var mode = entity switch
{
CacheEnum.EntityType.Patients => cacheSettings.Patients,
CacheEnum.EntityType.Displays => cacheSettings.Displays,
CacheEnum.EntityType.PumpObservations => cacheSettings.PumpObservations,
CacheEnum.EntityType.Appointments => cacheSettings.Appointments,
CacheEnum.EntityType.GroupedObservations => cacheSettings.GroupedObservations,
_ => CacheEnum.Mode.Cache
};
return mode switch
{
CacheEnum.Mode.Redis => redis,
CacheEnum.Mode.Cache => memory,
_ => noop
};
}
// Para GroupedObservations generamos la misma clave compuesta que el resto de servicios,
// de modo que el clasificador y la política de TTL funcionen igual.
/// <summary>
/// Builds a composite key used to identify grouped observations for a specific patient.
/// </summary>
/// <param name="gf">The grouped field whose name contributes to the key.</param>
/// <param name="patientId">The identifier of the patient associated with the grouped observation.</param>
/// <returns>A formatted key string in the form <c>GroupedObs:{patientId}:{gf.Name}</c>.</returns>
private static string BuildGroupedKey(GroupedField gf, ObjectId patientId)
=> $"GroupedObs:{patientId}:{gf.Name}";
=> $"GroupedObs:{patientId}:{gf.Name}";
/// <summary>
/// Selects the appropriate cache backend for the given grouped field and patient identifier by building a grouped key and resolving the backend through the key-based overload.
/// </summary>
/// <param name="groupedField">The grouped field used to derive the cache key.</param>
/// <param name="patientId">The patient identifier used to derive the cache key.</param>
/// <returns>The <see cref="ICacheService"/> backend associated with the built grouped key.</returns>
private ICacheService SelectBackend(GroupedField groupedField, ObjectId patientId)
=> SelectBackend(BuildGroupedKey(groupedField, patientId));
=> SelectBackend(BuildGroupedKey(groupedField, patientId));
// GetOrSet (KEY string)
/// <summary>
/// Asynchronously retrieves the object associated with the specified key from the selected backend, or sets it using the provided factory if it is not already cached.
/// </summary>
/// <param name="key">The cache key used to identify the object and to select the appropriate backend.</param>
/// <param name="factory">A delegate that asynchronously produces the value to store when the key is not present in the selected backend.</param>
/// <param name="ttl">An optional time-to-live duration for the cached object. If null, the backend's default expiration is applied.</param>
/// <param name="cancellationToken">A token to observe while waiting for the operation to complete.</param>
/// <returns>A task that represents the asynchronous operation, containing the retrieved or newly created object of type <typeparamref name="T"/>.</returns>
public Task<T> GetOrSetObjectAsync<T>(
string key,
Func<Task<T>> factory,
TimeSpan? ttl = null,
CancellationToken cancellationToken = default)
=> SelectBackend(key).GetOrSetObjectAsync(key, factory, ttl, cancellationToken);
string key,
Func<Task<T>> factory,
TimeSpan? ttl = null,
CancellationToken cancellationToken = default)
=> SelectBackend(key).GetOrSetObjectAsync(key, factory, ttl, cancellationToken);
/// <summary>
/// Asynchronously retrieves the value associated with the specified key from the backend selected for that key,
/// or loads and stores it using the provided loader function if it is not already present.
/// Supports an optional time-to-live (TTL) for the cached entry, and the returned value may be null.
/// </summary>
/// <param name="key">The key used to identify the cached value and to select the appropriate backend.</param>
/// <param name="loader">An asynchronous function that produces the value to cache when no existing entry is found.</param>
/// <param name="ttl">An optional time-to-live duration after which the cached entry expires. If null, the backend's default TTL is used.</param>
/// <returns>A task that represents the asynchronous operation, containing the cached or loaded string value, or null if no value could be obtained.</returns>
public Task<string?> GetOrSetValueAsync(
string key,
Func<Task<string>> loader,
TimeSpan? ttl = null)
=> SelectBackend(key).GetOrSetValueAsync(key, loader, ttl);
string key,
Func<Task<string>> loader,
TimeSpan? ttl = null)
=> SelectBackend(key).GetOrSetValueAsync(key, loader, ttl);
// GetOrSet (GroupedField + PatientId)
/// <summary>
/// Asynchronously retrieves the object associated with the specified grouped field and patient, or creates and stores it using the provided factory if it does not exist.
/// The appropriate backend is selected based on the grouped field and patient identifier before the underlying get-or-set operation is performed.
/// </summary>
/// <typeparam name="T">The type of the object to retrieve or create.</typeparam>
/// <param name="groupedField">The grouped field that determines the target backend and identifies the cached object.</param>
/// <param name="patientId">The identifier of the patient whose object is being retrieved or created.</param>
/// <param name="factory">The asynchronous factory used to create the object when no cached value is available.</param>
/// <param name="ttl">An optional time-to-live applied to the cached object. When <c>null</c>, the backend's default expiration is used.</param>
/// <param name="cancellationToken">The token to observe for canceling the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous get-or-set operation, containing the retrieved or newly created object.</returns>
public Task<T> GetOrSetObjectAsync<T>(
GroupedField groupedField,
ObjectId patientId,
Func<Task<T>> factory,
TimeSpan? ttl = null,
CancellationToken cancellationToken = default)
=> SelectBackend(groupedField, patientId)
.GetOrSetObjectAsync(groupedField, patientId, factory, ttl, cancellationToken);
GroupedField groupedField,
ObjectId patientId,
Func<Task<T>> factory,
TimeSpan? ttl = null,
CancellationToken cancellationToken = default)
=> SelectBackend(groupedField, patientId)
.GetOrSetObjectAsync(groupedField, patientId, factory, ttl, cancellationToken);
// Set/Get básicos
/// <summary>
/// Sets the value associated with the specified key by selecting the appropriate backend for that key and delegating the assignment to it.
/// </summary>
/// <param name="key">The key used to select the backend and identify the value to set.</param>
/// <param name="value">The value to associate with the specified key.</param>
public void SetValue(string key, string value)
=> SelectBackend(key).SetValue(key, value);
=> SelectBackend(key).SetValue(key, value);
/// <summary>
/// Retrieves the value associated with the specified key by delegating the lookup to a backend selected for that key.
/// </summary>
/// <param name="key">The key used to select the backend and retrieve the associated value.</param>
/// <returns>The value associated with the key, or <c>null</c> if the selected backend returns no value.</returns>
public string? GetValue(string key)
=> SelectBackend(key).GetValue(key);
=> SelectBackend(key).GetValue(key);
/// <summary>
/// Retrieves an object of type <typeparamref name="T"/> from the backend selected by the given key, with an option to trigger an update.
/// </summary>
/// <param name="key">The identifier used to select the appropriate backend and to look up the object.</param>
/// <param name="upd">Indicates whether the underlying backend should perform an update during retrieval. Defaults to <c>true</c>.</param>
/// <returns>A task that represents the asynchronous retrieval operation, containing the object of type <typeparamref name="T"/> or <c>null</c> if not found.</returns>
public Task<T?> GetObjectAsync<T>(string key, bool upd = true)
=> SelectBackend(key).GetObjectAsync<T>(key, upd);
=> SelectBackend(key).GetObjectAsync<T>(key, upd);
/// <summary>
/// Asynchronously stores an object in the backend selected by the specified key.
/// </summary>
/// <typeparam name="T">The type of the object to store.</typeparam>
/// <param name="key">The key used to select the backend and identify the stored object.</param>
/// <param name="obj">The object to store in the selected backend.</param>
/// <param name="upd">Indicates whether an update operation should be performed. Defaults to <c>true</c>.</param>
/// <returns>A task that represents the asynchronous store operation.</returns>
public Task SetObjectAsync<T>(string key, T obj, bool upd = true)
=> SelectBackend(key).SetObjectAsync(key, obj, upd);
=> SelectBackend(key).SetObjectAsync(key, obj, upd);
/// <summary>
/// Retrieves an object of the specified type from the backend selected by the given key, optionally applying a time-to-live and update behavior.
/// </summary>
/// <param name="key">The identifier used to select the backend and locate the stored object.</param>
/// <param name="ttl">An optional time-to-live applied to the object; if null, the backend's default is used.</param>
/// <param name="upd">A flag indicating whether the retrieval should update the object's state (e.g., refresh expiration).</param>
/// <returns>A task containing the deserialized object, or null if the object is not found in the selected backend.</returns>
public Task<T?> GetObjectAsync<T>(string key, TimeSpan? ttl, bool upd)
=> SelectBackend(key).GetObjectAsync<T>(key, ttl, upd);
=> SelectBackend(key).GetObjectAsync<T>(key, ttl, upd);
/// <summary>
/// Asynchronously stores an object in the backend selected for the specified key, with an optional time-to-live and update flag.
/// </summary>
/// <param name="key">The key used to select the target backend and identify the object.</param>
/// <param name="obj">The object to store.</param>
/// <param name="ttl">The optional time-to-live duration for the stored object.</param>
/// <param name="upd">Indicates whether to update an existing entry or create a new one.</param>
/// <returns>A task that represents the asynchronous set operation.</returns>
public Task SetObjectAsync<T>(string key, T obj, TimeSpan? ttl, bool upd)
=> SelectBackend(key).SetObjectAsync(key, obj, ttl, upd);
=> SelectBackend(key).SetObjectAsync(key, obj, ttl, upd);
/// <summary>
/// Asynchronously deletes the object identified by the specified key by delegating the operation to the backend selected for that key.
/// </summary>
/// <param name="key">The identifier of the object to delete, also used to resolve the responsible backend.</param>
/// <returns>A task that represents the asynchronous delete operation.</returns>
public Task DeleteObjectAsync(string key)
=> SelectBackend(key).DeleteObjectAsync(key);
=> SelectBackend(key).DeleteObjectAsync(key);
/// <summary>
/// Deletes entries matching the specified pattern from both Redis and in-memory storage, returning the total count of deleted entries.
/// </summary>
/// <param name="pattern">The pattern used to match entries for deletion in both storage backends.</param>
/// <returns>The combined total number of entries deleted from Redis and in-memory storage.</returns>
public async Task<long> DeleteByPatternAsync(string pattern)
=> await redis.DeleteByPatternAsync(pattern) + await memory.DeleteByPatternAsync(pattern);
=> await redis.DeleteByPatternAsync(pattern) + await memory.DeleteByPatternAsync(pattern);
/// <summary>
/// Clears all cached data from both the in-memory cache and the Redis cache, ensuring that stale entries are removed across all configured cache providers.
/// </summary>
public void CleanCache()
{
memory.CleanCache();
redis.CleanCache();
}
{
memory.CleanCache();
redis.CleanCache();
}
}
}
@@ -15,131 +15,214 @@ namespace adas_core.Application.Services.Caching
private readonly ConcurrentDictionary<string, object> _mem = new();
// HELPERS
/// <summary>
/// Builds a composite key for a grouped observation field, scoped to a specific patient.
/// </summary>
/// <param name="gf">The grouped field whose name is included in the key.</param>
/// <param name="patientId">The identifier of the patient the key is scoped to.</param>
/// <returns>A formatted key string in the form <c>GroupedObs:{patientId}:{gf.Name}</c>.</returns>
private static string BuildGroupedKey(GroupedField gf, ObjectId patientId)
=> $"GroupedObs:{patientId}:{gf.Name}";
=> $"GroupedObs:{patientId}:{gf.Name}";
// GET OR SET (string key)
/// <summary>
/// Retrieves an object from the in-memory cache by key, or creates and stores it using the provided factory if absent. Uses a fast path for cache hits and a lock-based path with a double-check to prevent duplicate creation across concurrent callers.
/// </summary>
/// <param name="key">The cache key used to look up and store the object.</param>
/// <param name="factory">The asynchronous factory function invoked to produce the object when it is not found in the cache.</param>
/// <param name="ttl">Optional time-to-live associated with the cached object.</param>
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
/// <returns>The cached or newly created object of type <typeparamref name="T"/>.</returns>
public async Task<T> GetOrSetObjectAsync<T>(
string key,
Func<Task<T>> factory,
TimeSpan? ttl = null,
CancellationToken cancellationToken = default)
{
// FAST PATH
if (_mem.TryGetValue(key, out var existing))
return (T)existing;
// LOCKED PATH
return await lockManager.WithLockAsync(
$"getorset:{key}",
TimeSpan.FromSeconds(5),
async () =>
string key,
Func<Task<T>> factory,
TimeSpan? ttl = null,
CancellationToken cancellationToken = default)
{
if (_mem.TryGetValue(key, out var again))
return (T)again;
var created = await factory();
if (created != null!)
_mem[key] = created;
return created!;
},
cancellationToken);
}
// FAST PATH
if (_mem.TryGetValue(key, out var existing))
return (T)existing;
// LOCKED PATH
return await lockManager.WithLockAsync(
$"getorset:{key}",
TimeSpan.FromSeconds(5),
async () =>
{
if (_mem.TryGetValue(key, out var again))
return (T)again;
var created = await factory();
if (created != null!)
_mem[key] = created;
return created!;
},
cancellationToken);
}
/// <summary>
/// Asynchronously retrieves the cached string value associated with the specified key, or loads and stores it using the provided loader function if absent.
/// Delegates to <see cref="GetOrSetObjectAsync"/> to handle caching, honoring the optional TTL override for the cache entry.
/// </summary>
/// <param name="key">The cache key used to identify the stored string value.</param>
/// <param name="loader">The asynchronous function invoked to load the value when no cached entry exists for the key.</param>
/// <param name="ttlOverride">An optional time span that overrides the default time-to-live for the cached value.</param>
/// <returns>The cached or newly loaded string value, or <c>null</c> when the underlying cache entry is absent or cannot be cast to a string.</returns>
public async Task<string?> GetOrSetValueAsync(
string key,
Func<Task<string>> loader,
TimeSpan? ttlOverride = null)
{
var result = await GetOrSetObjectAsync(key, loader, ttlOverride);
return (string?)result;
}
string key,
Func<Task<string>> loader,
TimeSpan? ttlOverride = null)
{
var result = await GetOrSetObjectAsync(key, loader, ttlOverride);
return (string?)result;
}
// GET OR SET (GroupedField + patientId)
/// <summary>
/// Asynchronously retrieves a cached object associated with the given grouped field and patient, or creates and caches a new one using the supplied factory when no cached value exists.
/// The factory is only invoked when the cache does not contain a value for the key, and the produced value is stored in the cache only when it is not null.
/// </summary>
/// <param name="groupedField">The grouped field that contributes to the cache key.</param>
/// <param name="patientId">The patient identifier that contributes to the cache key.</param>
/// <param name="factory">The asynchronous factory used to build the object when no cached value is available.</param>
/// <param name="ttl">Optional time-to-live for the cached entry.</param>
/// <param name="cancellationToken">Token used to cancel the asynchronous operation.</param>
/// <returns>A task containing the cached or newly created object.</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 (_mem.TryGetValue(key, out var existing))
return (T)existing;
return await lockManager.WithLockAsync(
$"getorset:{key}",
TimeSpan.FromSeconds(5),
async () =>
GroupedField groupedField,
ObjectId patientId,
Func<Task<T>> factory,
TimeSpan? ttl = null,
CancellationToken cancellationToken = default)
{
if (_mem.TryGetValue(key, out var again))
return (T)again;
var created = await factory();
if (created != null!)
_mem[key] = created;
return created!;
},
cancellationToken);
}
var key = BuildGroupedKey(groupedField, patientId);
if (_mem.TryGetValue(key, out var existing))
return (T)existing;
return await lockManager.WithLockAsync(
$"getorset:{key}",
TimeSpan.FromSeconds(5),
async () =>
{
if (_mem.TryGetValue(key, out var again))
return (T)again;
var created = await factory();
if (created != null!)
_mem[key] = created;
return created!;
},
cancellationToken);
}
// GET / SET
/// <summary>
/// Stores the specified value in the in-memory collection under the given key, overwriting any existing entry.
/// </summary>
/// <param name="key">The key that identifies where the value will be stored.</param>
/// <param name="value">The value to associate with the specified key.</param>
public void SetValue(string key, string value)
=> _mem[key] = value;
=> _mem[key] = value;
/// <summary>
/// Retrieves the string representation of the value associated with the specified key from the in-memory store.
/// Returns the value converted via <see cref="object.ToString"/> when the key is found, or <c>null</c> when the key is not present.
/// </summary>
/// <param name="key">The key used to look up the value in the underlying store.</param>
/// <returns>The string representation of the stored value if the key exists; otherwise, <c>null</c>.</returns>
public string? GetValue(string key)
=> _mem.TryGetValue(key, out var v) ? v.ToString() : null;
=> _mem.TryGetValue(key, out var v) ? v.ToString() : null;
/// <summary>
/// Asynchronously retrieves an object of type <typeparamref name="T"/> from the in-memory cache using the specified key.
/// Returns the stored value cast to <typeparamref name="T"/> if the key exists, or <c>default</c> (null for reference types) if the key is not found.
/// </summary>
/// <param name="key">The cache key used to look up the stored object.</param>
/// <param name="updateExpiration">Indicates whether the entry's expiration should be refreshed on access. Not currently used by this implementation.</param>
/// <returns>A <see cref="Task{T}"/> containing the cached value cast to <typeparamref name="T"/>, or <c>null</c> if no entry exists for the given key.</returns>
public Task<T?> GetObjectAsync<T>(string key, bool updateExpiration = true)
{
return Task.FromResult(
_mem.TryGetValue(key, out var v) ? (T?)v : default
);
}
{
return Task.FromResult(
_mem.TryGetValue(key, out var v) ? (T?)v : default
);
}
/// <summary>
/// Asynchronously stores the specified object in the in-memory cache using the given key.
/// </summary>
/// <param name="key">The cache key under which the object will be stored.</param>
/// <param name="obj">The object to store in the cache.</param>
/// <param name="updateExpiration">Indicates whether the cache entry's expiration should be refreshed.</param>
public Task SetObjectAsync<T>(string key, T obj, bool updateExpiration = true)
{
_mem[key] = obj!;
return Task.CompletedTask;
}
{
_mem[key] = obj!;
return Task.CompletedTask;
}
/// <summary>
/// Retrieves an object of type <typeparamref name="T"/> associated with the specified key by delegating to an overload that supports an update flag. The <paramref name="ttlOverride"/> parameter is accepted by this overload but is not forwarded to the underlying call.
/// </summary>
/// <param name="key">The identifier of the object to retrieve.</param>
/// <param name="ttlOverride">An optional time-to-live override accepted by this overload but ignored when delegating to the underlying retrieval call.</param>
/// <param name="upd">A flag indicating whether the retrieval should trigger an update on the stored object.</param>
/// <returns>A task that represents the asynchronous operation, containing the retrieved object of type <typeparamref name="T"/> or <c>null</c> if no object is found for the given key.</returns>
public Task<T?> GetObjectAsync<T>(string key, TimeSpan? ttlOverride, bool upd)
=> GetObjectAsync<T>(key, upd);
=> GetObjectAsync<T>(key, upd);
/// <summary>
/// Asynchronously stores an object associated with the specified key, with an option to override its time-to-live. The <paramref name="ttlOverride"/> parameter is accepted but is not forwarded to the underlying storage call, so the effective time-to-live is determined elsewhere.
/// </summary>
/// <param name="key">The identifier used to store and later retrieve the object.</param>
/// <param name="obj">The object to store in the underlying store.</param>
/// <param name="ttlOverride">An optional time-to-live override for the stored entry; not applied by this overload.</param>
/// <param name="upd">A flag indicating whether the operation should update an existing entry.</param>
/// <returns>A task that represents the asynchronous set operation.</returns>
public Task SetObjectAsync<T>(string key, T obj, TimeSpan? ttlOverride, bool upd)
=> SetObjectAsync(key, obj, upd);
=> SetObjectAsync(key, obj, upd);
// DELETE / CLEAN
/// <summary>
/// Asynchronously removes the object associated with the specified key from the in-memory store. The operation succeeds silently whether or not the key exists.
/// </summary>
/// <param name="key">The identifier of the object to delete.</param>
public Task DeleteObjectAsync(string key)
{
_mem.TryRemove(key, out _);
return Task.CompletedTask;
}
{
_mem.TryRemove(key, out _);
return Task.CompletedTask;
}
/// <summary>
/// Asynchronously deletes cache entries whose keys contain the specified pattern, where asterisk (*) characters in the pattern are treated as wildcards (stripped and matched as substrings). Returns the number of entries successfully removed.
/// </summary>
/// <param name="pattern">The pattern to match against cache keys. Asterisk (*) characters are removed and the remaining text is used as a substring match.</param>
/// <returns>A task that represents the asynchronous operation, containing the count of entries that were removed.</returns>
public Task<long> DeleteByPatternAsync(string pattern)
{
var p = pattern.Replace("*", "");
var keys = _mem.Keys.Where(k => k.Contains(p)).ToList();
long removed = 0;
foreach (var k in keys)
if (_mem.TryRemove(k, out _))
removed++;
return Task.FromResult(removed);
}
{
var p = pattern.Replace("*", "");
var keys = _mem.Keys.Where(k => k.Contains(p)).ToList();
long removed = 0;
foreach (var k in keys)
if (_mem.TryRemove(k, out _))
removed++;
return Task.FromResult(removed);
}
/// <summary>
/// Clears all entries from the in-memory cache, removing any previously stored data.
/// </summary>
public void CleanCache() => _mem.Clear();
}
}
@@ -11,50 +11,105 @@ namespace adas_core.Application.Services.Caching
/// </summary>
public class NoCacheService : ICacheService
{
/// <summary>
/// Stub implementation that performs no action. Intended as a placeholder for storing a value associated with the specified key.
/// </summary>
/// <param name="key">The identifier used to reference the value.</param>
/// <param name="value">The value intended to be associated with the key.</param>
public void SetValue(string key, string value)
{
// No hacer nada
}
{
// No hacer nada
}
/// <summary>
/// Retrieves the string value associated with the specified key.
/// Returns <c>null</c> when no value is found for the given key.
/// </summary>
/// <param name="key">The key used to look up the associated value.</param>
/// <returns>The value associated with <paramref name="key"/>, or <c>null</c> if no value is found.</returns>
public string? GetValue(string key)
{
return null;
}
{
return null;
}
/// <summary>
/// Asynchronously retrieves an object of type <typeparamref name="T"/> associated with the specified <paramref name="key"/>.
/// </summary>
/// <param name="key">The identifier of the object to retrieve.</param>
/// <param name="updateExpiration">Indicates whether the expiration of the entry should be updated upon retrieval.</param>
/// <returns>A <see cref="Task{T}"/> that represents the asynchronous retrieval, containing the object associated with the key or the default value of <typeparamref name="T"/> if not found.</returns>
public Task<T?> GetObjectAsync<T>(string key, bool updateExpiration = true)
{
return Task.FromResult<T?>(default);
}
{
return Task.FromResult<T?>(default);
}
/// <summary>
/// Asynchronously stores the specified object associated with the given key in the underlying data store.
/// When <paramref name="updateExpiration"/> is true, the expiration of the entry is refreshed; otherwise the existing expiration is preserved.
/// </summary>
/// <param name="key">The unique identifier used to store and later retrieve the object.</param>
/// <param name="obj">The object to store in the data store.</param>
/// <param name="updateExpiration">Indicates whether the expiration time of the cached entry should be updated. Defaults to true.</param>
public Task SetObjectAsync<T>(string key, T obj, bool updateExpiration = true)
{
return Task.CompletedTask;
}
{
return Task.CompletedTask;
}
/// <summary>
/// Asynchronously retrieves an object of type <typeparamref name="T"/> associated with the specified <paramref name="key"/>.
/// Supports an optional time-to-live override and an option to update the expiration of the stored entry.
/// </summary>
/// <param name="key">The unique identifier used to look up the stored object.</param>
/// <param name="ttlOverride">An optional time-to-live value that, when provided, overrides the default expiration for the entry.</param>
/// <param name="updateExpiration">Indicates whether the expiration of the entry should be refreshed upon a successful retrieval.</param>
/// <returns>A <see cref="Task{T}"/> that represents the asynchronous operation, containing the retrieved object or <c>null</c> if no value is found.</returns>
public Task<T?> GetObjectAsync<T>(string key, TimeSpan? ttlOverride, bool updateExpiration)
{
return Task.FromResult<T?>(default);
}
{
return Task.FromResult<T?>(default);
}
/// <summary>
/// Asynchronously stores an object associated with the specified key, optionally overriding the time-to-live and updating the expiration.
/// </summary>
/// <param name="key">The identifier used to store and retrieve the object.</param>
/// <param name="obj">The object to be stored.</param>
/// <param name="ttlOverride">An optional time-to-live value that overrides the default expiration period; <c>null</c> uses the default.</param>
/// <param name="updateExpiration">A value indicating whether the expiration time should be updated.</param>
/// <returns>A task that represents the asynchronous set operation.</returns>
public Task SetObjectAsync<T>(string key, T obj, TimeSpan? ttlOverride, bool updateExpiration)
{
return Task.CompletedTask;
}
{
return Task.CompletedTask;
}
/// <summary>
/// Asynchronously deletes items matching the specified pattern and returns the number of items removed.
/// This implementation is a stub that always returns 0, performing no actual deletion regardless of the provided pattern.
/// </summary>
/// <param name="pattern">The pattern used to identify the items to delete.</param>
/// <returns>A <see cref="Task{Int64}"/> representing the asynchronous operation, with a result of 0 indicating that no items were deleted.</returns>
public Task<long> DeleteByPatternAsync(string pattern)
{
return Task.FromResult(0L);
}
{
return Task.FromResult(0L);
}
/// <summary>
/// Asynchronously deletes the object identified by the specified key.
/// The operation completes immediately without performing an actual deletion.
/// </summary>
/// <param name="key">The identifier of the object to delete.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous delete operation.</returns>
public Task DeleteObjectAsync(string key)
{
return Task.CompletedTask;
}
{
return Task.CompletedTask;
}
/// <summary>
/// Performs a cleanup operation on the cache. Currently, this method has no implementation and does not perform any cleanup actions.
/// </summary>
public void CleanCache()
{
// Nada que limpiar
}
{
// Nada que limpiar
}
// ============================================================
// GET OR SET - STRING KEY
@@ -70,14 +125,21 @@ namespace adas_core.Application.Services.Caching
return await factory();
}
/// <summary>
/// Asynchronously loads a value using the provided loader function.
/// </summary>
/// <param name="key">The key associated with the value to retrieve or set.</param>
/// <param name="loader">The asynchronous function used to load the value.</param>
/// <param name="ttl">An optional time-to-live duration for the value.</param>
/// <returns>A task that represents the asynchronous operation, containing the loaded value as a nullable string.</returns>
public async Task<string?> GetOrSetValueAsync(
string key,
Func<Task<string>> loader,
TimeSpan? ttl = null)
{
var result = await loader();
return (string?)result;
}
string key,
Func<Task<string>> loader,
TimeSpan? ttl = null)
{
var result = await loader();
return (string?)result;
}
// ============================================================
// GET OR SET - GroupedField + patientId
@@ -24,44 +24,55 @@ namespace adas_core.Application.Services.Caching
private readonly ConcurrentDictionary<string, string> _tokens =
new(StringComparer.Ordinal);
/// <summary>
/// Attempts to acquire a distributed lock for the specified key using Redis, retrying until the timeout expires.
/// Returns <c>false</c> if the Redis database is unavailable or if the lock cannot be acquired within the given timeout.
/// </summary>
/// <param name="key">The identifier of the resource to lock.</param>
/// <param name="timeout">The maximum duration to keep retrying before giving up.</param>
/// <returns><c>true</c> if the lock was successfully acquired; otherwise, <c>false</c>.</returns>
public async Task<bool> AcquireAsync(string key, TimeSpan timeout)
{
var redis = getDatabase();
if (redis is null) return false;
var redisKey = (RedisKey)(_prefix + key);
var token = Guid.NewGuid().ToString("N");
var end = DateTime.UtcNow.Add(timeout);
while (DateTime.UtcNow < end)
{
if (await redis.StringSetAsync(redisKey, token, _ttl, When.NotExists))
{
_tokens[key] = token;
return true;
var redis = getDatabase();
if (redis is null) return false;
var redisKey = (RedisKey)(_prefix + key);
var token = Guid.NewGuid().ToString("N");
var end = DateTime.UtcNow.Add(timeout);
while (DateTime.UtcNow < end)
{
if (await redis.StringSetAsync(redisKey, token, _ttl, When.NotExists))
{
_tokens[key] = token;
return true;
}
await Task.Delay(_retryDelay);
}
return false;
}
await Task.Delay(_retryDelay);
}
return false;
}
/// <summary>
/// Asynchronously releases the token associated with the specified key by removing it from the in-memory token store and executing a Lua release script against Redis. If the key is not found in the local store, or the Redis database is unavailable, the method returns without performing any further action.
/// </summary>
/// <param name="key">The identifier of the token to release.</param>
public async Task ReleaseAsync(string key)
{
if (!_tokens.TryRemove(key, out var token))
return;
var redis = getDatabase();
if (redis is null) return;
var redisKey = (RedisKey)(_prefix + key);
await redis.ScriptEvaluateAsync(
LuaReleaseScript,
[redisKey],
[token]
);
}
{
if (!_tokens.TryRemove(key, out var token))
return;
var redis = getDatabase();
if (redis is null) return;
var redisKey = (RedisKey)(_prefix + key);
await redis.ScriptEvaluateAsync(
LuaReleaseScript,
[redisKey],
[token]
);
}
}
}
@@ -13,6 +13,9 @@ 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;
@@ -40,236 +43,334 @@ namespace adas_core.Application.Services.Caching
// 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 () =>
string key,
Func<Task<T>> factory,
TimeSpan? ttl = null,
CancellationToken cancellationToken = default)
{
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);
}
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 () =>
string key,
Func<Task<string>> loader,
TimeSpan? ttl = null)
{
var again = GetValue(key);
if (again is not null)
return again;
var created = await loader();
SetValue(key, created);
return created;
});
}
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}";
=> $"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 () =>
GroupedField groupedField,
ObjectId patientId,
Func<Task<T>> factory,
TimeSpan? ttl = null,
CancellationToken cancellationToken = default)
{
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);
}
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);
=> _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;
}
{
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);
}
}
{
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);
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);
}
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);
}
{
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;
}
{
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();
=> _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;
}
{
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;
=> 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);
}
}
{
_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);
=> GetObjectAsync<T>(key, updateExpiration);
}
}