diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 924a7719..6e2e244c 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,10 +3,11 @@ "isRoot": true, "tools": { "csharpier": { - "version": "1.2.6", + "version": "1.3.0", "commands": [ "csharpier" - ] + ], + "rollForward": false } } } \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index daaa046b..ab923345 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,6 +50,7 @@ jobs: dotnet-version: '10.0.x' - name: 🔧 Install SonarCloud scanner + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository run: dotnet tool install --global dotnet-sonarscanner - name: 📡 Install DeepSource scanner @@ -66,6 +67,7 @@ jobs: run: dotnet restore - name: 🔍 Begin SonarCloud analysis + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository run: | dotnet sonarscanner begin \ /k:"guibranco_CrispyWaffle" \ @@ -89,12 +91,14 @@ jobs: /p:CoverletOutputFormat=opencover%2ccobertura - name: 📊 End SonarCloud analysis + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository run: dotnet sonarscanner end /d:sonar.token="$SONAR_TOKEN" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - name: ☂️ Upload coverage to Codecov + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.vscode/settings.json b/.vscode/settings.json index 804a4175..a69c5d00 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,5 +22,6 @@ "sonarlint.connectedMode.project": { "connectionId": "guibranco", "projectKey": "guibranco_CrispyWaffle" - } + }, + "snyk.advanced.autoSelectOrganization": true } \ No newline at end of file diff --git a/Src/CrispyWaffle.CouchDB/Cache/CouchDBCacheRepository.cs b/Src/CrispyWaffle.CouchDB/Cache/CouchDBCacheRepository.cs index f480684e..7100f063 100644 --- a/Src/CrispyWaffle.CouchDB/Cache/CouchDBCacheRepository.cs +++ b/Src/CrispyWaffle.CouchDB/Cache/CouchDBCacheRepository.cs @@ -1,8 +1,10 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using CouchDB.Driver; +using CouchDB.Driver.Extensions; using CrispyWaffle.Cache; using CrispyWaffle.CouchDB.Utils.Communications; using CrispyWaffle.Log; @@ -42,31 +44,32 @@ public class CouchDBCacheRepository : ICacheRepository, IDisposable /// Finally, it converts the filtered results to a list and returns the count of those documents. /// This is useful for determining how many valid instances of a specific document type exist in the database. /// - public int GetDocCount() - where T : CouchDBCacheDocument => - ResolveDatabase().Where(x => x.Id != null).ToList().Count; + public async Task GetDocCount() + where T : CouchDBCacheDocument + { + var db = await ResolveDatabase().ConfigureAwait(false); + return db.Where(x => x.Id != null).ToList().Count; + } - /// - public void Clear() => Clear(); + /// + /// Clears all documents from the cache database. + /// + /// A task representing the asynchronous clear operation. + /// Thrown if ShouldPropagateExceptions is true and an error occurs during deletion. + public async Task Clear() => await Clear(); /// - /// Clears all documents of type from the CouchDB database. + /// Clears all documents of the specified type from the cache database. /// - /// The type of documents to be cleared, which must inherit from . - /// - /// This method retrieves all documents of the specified type from the database that have a non-null Id. - /// It then creates a list of asynchronous delete tasks for each document and waits for all tasks to complete. - /// If an exception occurs during the process, it checks whether exceptions should be propagated or handled. - /// If propagation is not desired, the exception is logged using the . - /// This method does not return any value and modifies the state of the database by removing documents. - /// - /// Thrown when an error occurs during the deletion process, unless exceptions are suppressed. - public void Clear() + /// The document type to clear, must inherit from CouchDBCacheDocument. + /// A task representing the asynchronous clear operation. + /// Thrown if ShouldPropagateExceptions is true and an error occurs during deletion. + public async Task Clear() where T : CouchDBCacheDocument { try { - var db = ResolveDatabase(); + var db = await ResolveDatabase().ConfigureAwait(false); var docs = db.Where(x => x.Id != null).ToList(); var tasks = new List(docs.Count); @@ -89,14 +92,18 @@ public void Clear() } /// - public T Get(string key) + public async ValueTask GetAsync(string key, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + if (!typeof(CouchDBCacheDocument).IsAssignableFrom(typeof(T))) { return default; } - return (T)(object)GetSpecific(key); + var result = await GetSpecificAsync(key); + + return (T)(object)result; } /// @@ -110,18 +117,26 @@ public T Get(string key) /// /// /// This method checks if the specified type is assignable from . - /// If it is, it calls the method to retrieve the cached document associated with the provided keys. + /// If it is, it calls the method to retrieve the cached document associated with the provided keys. /// If the type is not assignable, it returns the default value for that type, which could be null for reference types or zero for numeric types. /// This method is useful for retrieving cached data in a type-safe manner, ensuring that only compatible types are processed. /// - public T Get(string key, string subKey) + public async ValueTask GetAsync( + string key, + string subKey, + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + if (!typeof(CouchDBCacheDocument).IsAssignableFrom(typeof(T))) { return default; } - return (T)(object)GetSpecific(key, subKey); + var result = await GetSpecificAsync(key, subKey, cancellationToken); + + return (T)(object)result; } /// @@ -129,23 +144,36 @@ public T Get(string key, string subKey) /// /// Type T with base type . /// A uniquely identifiable key to get document from the specified database. + /// Token to cancel the operation. /// The document if found. + /// Thrown if the operation is cancelled. /// Thrown in case the operation fails. - public T GetSpecific(string key) + public async Task GetSpecificAsync( + string key, + CancellationToken cancellationToken = default + ) where T : CouchDBCacheDocument { try { - var doc = ResolveDatabase().Where(x => x.Key == key).FirstOrDefault(); + cancellationToken.ThrowIfCancellationRequested(); + + var clientDb = await ResolveDatabase().ConfigureAwait(false); + var doc = await clientDb.FindAsync(key); if (doc != default && doc.ExpiresAt != default && doc.ExpiresAt <= DateTime.UtcNow) { - RemoveSpecific(key); + await RemoveSpecificAsync(key, cancellationToken).ConfigureAwait(false); return default; } return doc; } + catch (OperationCanceledException) + { + LogConsumer.Warning($"Operation cancelled while getting document with key: {key}"); + throw; + } catch (Exception e) { if (ShouldPropagateExceptions) @@ -160,37 +188,44 @@ public T GetSpecific(string key) } /// - /// Retrieves a specific document from the CouchDB cache based on the provided key and subKey. + /// Retrieves a specific cached document by key and subkey, removing it if expired. /// - /// The type of the document to retrieve, which must inherit from . - /// The primary key used to identify the document. - /// The secondary key used to further specify the document. - /// The document of type if found and not expired; otherwise, returns default for the type. - /// - /// This method queries the database for a document that matches the specified and . - /// If a matching document is found, it checks whether the document has expired by comparing its expiration time with the current UTC time. - /// If the document has expired, it is removed from the cache, and the method returns default. - /// If an exception occurs during the database operation, it is either logged or propagated based on the ShouldPropagateExceptions flag. - /// If no document is found and no exceptions are thrown, an is thrown indicating that the item could not be retrieved. - /// - /// Thrown when unable to retrieve the item with the specified key and sub key. - public T GetSpecific(string key, string subKey) + /// The document type to retrieve, must inherit from CouchDBCacheDocument. + /// The primary cache key. + /// The secondary cache key. + /// Token to cancel the operation. + /// The cached document if found and not expired; otherwise null. + /// Thrown if the operation is cancelled. + /// Thrown if ShouldPropagateExceptions is true and an error occurs. + public async Task GetSpecificAsync( + string key, + string subKey, + CancellationToken cancellationToken = default + ) where T : CouchDBCacheDocument { try { - var doc = ResolveDatabase() - .Where(x => x.Key == key && x.SubKey == subKey) - .FirstOrDefault(); + cancellationToken.ThrowIfCancellationRequested(); + + var client = await ResolveDatabase().ConfigureAwait(false); + var doc = client.Where(x => x.Key == key && x.SubKey == subKey).FirstOrDefault(); if (doc != default && doc.ExpiresAt != default && doc.ExpiresAt <= DateTime.UtcNow) { - RemoveSpecific(key, subKey); + await RemoveSpecificAsync(key, subKey, cancellationToken).ConfigureAwait(false); return default; } return doc; } + catch (OperationCanceledException) + { + LogConsumer.Warning( + $"Operation cancelled while getting document with key and subkey: {key} {subKey}" + ); + throw; + } catch (Exception e) { if (ShouldPropagateExceptions) @@ -207,42 +242,83 @@ public T GetSpecific(string key, string subKey) } /// - public void Remove(string key) => RemoveSpecific(key); + /// A task representing the asynchronous operation. + /// Token to cancel the operation. + /// Key to be removed. + public async Task RemoveAsync(string key, CancellationToken cancellationToken = default) + { + await RemoveSpecificAsync(key, cancellationToken) + .ConfigureAwait(false); + } /// /// Removes a specific entry from the cache based on the provided key and subKey. /// /// The primary key associated with the entry to be removed. /// The secondary key associated with the entry to be removed. + /// Token to cancel the operation. /// /// This method calls the generic method RemoveSpecific with the type CouchDBCacheDocument to remove the entry /// identified by the combination of and . /// It is important to ensure that both keys are correctly specified to successfully remove the intended entry from the cache. /// If the specified entry does not exist, no action will be taken, and no exceptions will be thrown. /// - public void Remove(string key, string subKey) => - RemoveSpecific(key, subKey); + /// A task representing the asynchronous operation + public async Task RemoveAsync( + string key, + string subKey, + CancellationToken cancellationToken = default + ) + { + await RemoveSpecificAsync(key, subKey, cancellationToken) + .ConfigureAwait(false); + } /// /// Removes from a class specified database instead of the general database. /// /// Type T with base type . /// A uniquely identifiable key to remove document from the specified database. - public void RemoveSpecific(string key) + /// Token to cancel the operation. + /// A task representing the asynchronous operation. + public async Task RemoveSpecificAsync( + string key, + CancellationToken cancellationToken = default + ) where T : CouchDBCacheDocument { + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException("Key cannot be null, empty, or whitespace.", nameof(key)); + } + try { - var db = _connector.CouchDBClient.GetDatabase(); - var doc = db.Where(x => x.Key == key).FirstOrDefault(); + cancellationToken.ThrowIfCancellationRequested(); - if (doc != default) + // Using synchronous database resolution if async version not available + var db = await ResolveDatabase().ConfigureAwait(false); + var doc = await db.FindAsync(key).ConfigureAwait(false); + + if (doc != null) + { + await db.DeleteAsync(doc).ConfigureAwait(false); + LogConsumer.Info($"Successfully removed document with key: {key}"); + } + else { - db.DeleteAsync(doc).Wait(); + LogConsumer.Info($"Document with key: {key} not found for removal"); } } + catch (OperationCanceledException) + { + LogConsumer.Warning($"Operation cancelled while removing document with key: {key}"); + throw; + } catch (Exception e) { + LogConsumer.Error($"Failed to remove document with key: {key}", e); + if (ShouldPropagateExceptions) { throw; @@ -258,6 +334,7 @@ public void RemoveSpecific(string key) /// The type of the CouchDB document, which must inherit from . /// The key of the document to be removed. /// The subKey of the document to be removed. + /// Token to cancel the operation /// /// This method retrieves a document from the CouchDB database that matches the specified /// and . If a matching document is found, it is deleted asynchronously from the database. @@ -265,21 +342,47 @@ public void RemoveSpecific(string key) /// . If exceptions are not propagated, they are logged using the /// . This method does not return any value. /// - public void RemoveSpecific(string key, string subKey) + /// A task representing the asynchronous operation. + /// Thrown when key is null, empty, or whitespace. + /// Thrown when the operation is cancelled. + public async Task RemoveSpecificAsync( + string key, + string subKey, + CancellationToken cancellationToken = default + ) where T : CouchDBCacheDocument { + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException("Key cannot be null, empty, or whitespace.", nameof(key)); + } + try { - var db = _connector.CouchDBClient.GetDatabase(); + cancellationToken.ThrowIfCancellationRequested(); + + var db = await ResolveDatabase().ConfigureAwait(false); var doc = db.Where(x => x.Key == key && x.SubKey == subKey).FirstOrDefault(); - if (doc != default) + if (doc != null) { - db.DeleteAsync(doc).Wait(); + await db.DeleteAsync(doc).ConfigureAwait(false); + LogConsumer.Info($"Successfully removed document with key: {key}"); } + else + { + LogConsumer.Info($"Document with key: {key} not found for removal"); + } + } + catch (OperationCanceledException) + { + LogConsumer.Warning($"Operation cancelled while removing document with key: {key}"); + throw; } catch (Exception e) { + LogConsumer.Error($"Failed to remove document with key: {key}", e); + if (ShouldPropagateExceptions) { throw; @@ -290,14 +393,19 @@ public void RemoveSpecific(string key, string subKey) } /// - public void Set(T value, string key, TimeSpan? ttl = null) + public async ValueTask SetAsync( + T value, + string key, + TimeSpan? ttl = null, + CancellationToken cancellationToken = default + ) { if (!typeof(CouchDBCacheDocument).IsAssignableFrom(typeof(T))) { return; } - SetSpecific((CouchDBCacheDocument)(object)value, key, ttl); + await SetSpecificAsync((CouchDBCacheDocument)(object)value, key, ttl, cancellationToken); } /// @@ -307,20 +415,59 @@ public void Set(T value, string key, TimeSpan? ttl = null) /// The value to be set in the cache. /// The primary key under which the value is stored. /// The sub-key under which the value is stored. + /// Token to cancel the operation. /// /// This method checks if the provided type is assignable from . /// If it is not, the method returns without performing any action. /// If the type is valid, it calls the method to set the value in the cache. /// This allows for type-safe caching of documents that inherit from . /// - public void Set(T value, string key, string subKey) + /// Thrown when the operation is cancelled. + /// A ValueTask representing the asynchronous operation. + public async ValueTask SetAsync( + T value, + string key, + string subKey, + CancellationToken cancellationToken = default + ) { - if (!typeof(CouchDBCacheDocument).IsAssignableFrom(typeof(T))) + try { - return; + cancellationToken.ThrowIfCancellationRequested(); + if (!typeof(CouchDBCacheDocument).IsAssignableFrom(typeof(T))) + { + return; + } + + await SetSpecificAsync( + (CouchDBCacheDocument)(object)value, + key, + subKey, + cancellationToken + ) + .ConfigureAwait(false); } + catch (OperationCanceledException) + { + // Always propagate cancellation + LogConsumer.Warning( + $"Operation cancelled while setting document with key: {key}, subKey: {subKey}" + ); + throw; + } + catch (Exception ex) + { + // Log and wrap with context + LogConsumer.Error( + $"Failed to set cache item with key: '{key}', subKey: '{subKey}', type: {typeof(T).Name}", + ex + ); - SetSpecific((CouchDBCacheDocument)(object)value, key, subKey); + if (ShouldPropagateExceptions) + { + throw; + } + } } /// @@ -330,11 +477,18 @@ public void Set(T value, string key, string subKey) /// The value of type T to be persisted. /// A uniquely identifiable key to remove document from the specified database. /// How long the value should be stored. - public void SetSpecific(T value, string key, TimeSpan? ttl = null) + /// A ValueTask representing the asynchronous operation + public async Task SetSpecificAsync( + T value, + string key, + TimeSpan? ttl = null, + CancellationToken cancellationToken = default + ) where T : CouchDBCacheDocument { try { + cancellationToken.ThrowIfCancellationRequested(); value.Key = key; if (ttl != null) @@ -343,7 +497,8 @@ public void SetSpecific(T value, string key, TimeSpan? ttl = null) value.ExpiresAt = DateTime.UtcNow.Add(ttl.Value); } - ResolveDatabase().CreateAsync(value).Wait(); + var clientDB = await ResolveDatabase().ConfigureAwait(false); + await clientDB.CreateAsync(value); } catch (Exception e) { @@ -363,6 +518,7 @@ public void SetSpecific(T value, string key, TimeSpan? ttl = null) /// The CouchDBCacheDocument instance to be updated. /// The key to be set for the CouchDBCacheDocument. /// The subKey to be set for the CouchDBCacheDocument. + /// Token to cancel the operation /// /// This method assigns the provided and to the specified /// of type . It then attempts to create or update the @@ -371,15 +527,32 @@ public void SetSpecific(T value, string key, TimeSpan? ttl = null) /// is rethrown; otherwise, it is logged using the LogConsumer. /// /// Thrown when an error occurs during the database operation, unless exceptions are suppressed. - public void SetSpecific(T value, string key, string subKey) + /// Thrown when the operation is cancelled + /// A task representing the asynchronous operation + public async Task SetSpecificAsync( + T value, + string key, + string subKey, + CancellationToken cancellationToken = default + ) where T : CouchDBCacheDocument { try { - value.Key = key; - value.SubKey = subKey; + cancellationToken.ThrowIfCancellationRequested(); + value.Key = key.Trim(); + value.SubKey = subKey.Trim(); - ResolveDatabase().CreateOrUpdateAsync(value).Wait(); + var clientDB = await ResolveDatabase().ConfigureAwait(false); + await clientDB.CreateOrUpdateAsync(value).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Always propagate cancellation + LogConsumer.Warning( + $"Operation cancelled while setting document with key: {key}, subKey: {subKey}" + ); + throw; } catch (Exception e) { @@ -393,53 +566,101 @@ public void SetSpecific(T value, string key, string subKey) } /// - public bool TryGet(string key, out T value) + public async ValueTask<(bool Success, T Value)> TryGetAsync( + string key, + CancellationToken cancellationToken = default + ) { - var get = Get(key); + try + { + cancellationToken.ThrowIfCancellationRequested(); + var get = await GetAsync(key, cancellationToken); - if (get != default) + if (get != default) + { + var value = (T)(object)get; + return (true, value); + } + } + catch (OperationCanceledException) { - value = (T)(object)get; - return true; + // Always propagate cancellation + LogConsumer.Warning($"Operation cancelled while setting document with key: {key}"); + return (false, default(T)); } + catch (Exception e) + { + if (ShouldPropagateExceptions) + { + throw; + } - value = default; - return false; + LogConsumer.Handle(e); + } + + return (false, default(T)); } /// - /// Tries to retrieve a value associated with the specified keys from the cache. + /// Attempts to retrieve a cached document by key and subkey. /// /// The type of the value to retrieve. /// The primary key used to access the cached value. /// The secondary key used to access the cached value. - /// When this method returns, contains the retrieved value if successful; otherwise, the default value for type . - /// True if the value was found and retrieved successfully; otherwise, false. + /// Token to cancel the operation. + /// + /// A tuple containing Success (true if found and retrieved) and Value (the document cast to T or default(T)). + /// /// /// This method attempts to fetch a cached document from a data store using a combination of a primary key and a secondary key. - /// If the document is found, it is cast to the specified type and returned through the out parameter . - /// If no document is found, is set to its default value, and the method returns false. + /// If the document is found, it is cast to the specified type and returned./>. /// This is useful for safely attempting to retrieve values without throwing exceptions if the keys do not exist in the cache. /// - public bool TryGet(string key, string subKey, out T value) + /// Thrown if the operation is cancelled. + /// Thrown if ShouldPropagateExceptions is true and an error occurs. + public async ValueTask<(bool Success, T Value)> TryGetAsync( + string key, + string subKey, + CancellationToken cancellationToken = default + ) { - var get = Get(key, subKey); - - if (get != default) + try + { + cancellationToken.ThrowIfCancellationRequested(); + var get = await GetAsync(key, subKey, cancellationToken); + if (get != default) + { + var value = (T)(object)get; + return (true, value); + } + } + catch (OperationCanceledException) { - value = (T)(object)get; - return true; + // Always propagate cancellation + LogConsumer.Warning( + $"Operation cancelled while setting document with key and subkey: {key} {subKey}" + ); + throw; } + catch (Exception e) + { + if (ShouldPropagateExceptions) + { + throw; + } - value = default; - return false; + LogConsumer.Handle(e); + } + + return (false, default(T)); } /// /// Retrieves the Time-To-Live (TTL) value for a specified cache key. /// /// The key for which the TTL value is to be retrieved. - /// The TimeSpan representing the TTL for the specified . + /// Token to cancel the operation. + /// The TTL of the cached document, or TimeSpan.Zero if not found or no TTL set /// /// This method accesses a CouchDB document associated with the provided /// and retrieves its Time-To-Live (TTL) value. The TTL indicates the duration for which the @@ -448,7 +669,37 @@ public bool TryGet(string key, string subKey, out T value) /// This method assumes that the key exists in the cache; if it does not, the behavior will depend /// on the implementation of the Get method. /// - public TimeSpan TTL(string key) => Get(key).TTL; + /// Thrown when the operation is cancelled + public async Task TTLAsync(string key, CancellationToken cancellationToken = default) + { + try + { + cancellationToken.ThrowIfCancellationRequested(); + + var document = await GetAsync(key, cancellationToken) + .ConfigureAwait(false); + + return document?.TTL ?? TimeSpan.Zero; + } + catch (OperationCanceledException) + { + // Always propagate cancellation + LogConsumer.Warning($"Operation cancelled while setting document with key: {key}"); + throw; + } + catch (Exception ex) + { + LogConsumer.Error($"Failed to get TTL for key: '{key}'", ex); + + if (ShouldPropagateExceptions) + { + throw; + } + + // Return default value on error + return TimeSpan.Zero; + } + } /// /// Resolves a CouchDatabase instance for the specified database name or defaults to the type name if none is provided. @@ -462,7 +713,7 @@ public bool TryGet(string key, string subKey, out T value) /// ensuring that it handles database creation and retrieval efficiently. The use of generics allows for flexibility in specifying the type of documents /// that will be stored in the database, making this method suitable for various CouchDBCacheDocument types. /// - private CouchDatabase ResolveDatabase(string dbName = default) + private async Task> ResolveDatabase(string dbName = default) where T : CouchDBCacheDocument { if (string.IsNullOrEmpty(dbName)) @@ -470,8 +721,8 @@ private CouchDatabase ResolveDatabase(string dbName = default) dbName = $"{typeof(T).Name.ToLowerInvariant()}s"; } - return !_connector.CouchDBClient.GetDatabasesNamesAsync().Result.Contains(dbName) - ? _connector.CouchDBClient.CreateDatabaseAsync().Result + return !(await _connector.CouchDBClient.GetDatabasesNamesAsync()).Contains(dbName) + ? await _connector.CouchDBClient.CreateDatabaseAsync(dbName) : _connector.CouchDBClient.GetDatabase(); } diff --git a/Src/CrispyWaffle.Redis/Cache/RedisCacheRepository.cs b/Src/CrispyWaffle.Redis/Cache/RedisCacheRepository.cs index 59d39012..6a08bfbc 100644 --- a/Src/CrispyWaffle.Redis/Cache/RedisCacheRepository.cs +++ b/Src/CrispyWaffle.Redis/Cache/RedisCacheRepository.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Threading; +using System.Threading.Tasks; using CrispyWaffle.Cache; using CrispyWaffle.Log; using CrispyWaffle.Redis.Utils.Communications; @@ -152,19 +154,56 @@ public void RemoveFromDatabase(string key, int databaseNumber) => /// The value. /// The key. /// The TTL. - public void Set(T value, string key, TimeSpan? ttl = null) + /// To cancel operation. + /// A valuetask representing the asynchronous operation. + public async ValueTask SetAsync( + T value, + string key, + TimeSpan? ttl = null, + CancellationToken cancellationToken = default + ) { try { + // Throw if cancellation is requested before starting + cancellationToken.ThrowIfCancellationRequested(); + + Task cacheOperation; + if (ttl.HasValue) { - _cacheClient.Db0.AddAsync(key, value, ttl.Value).Wait(); + cacheOperation = _cacheClient.Db0.AddAsync(key, value, ttl.Value); + } + else + { + cacheOperation = _cacheClient.Db0.AddAsync(key, value); + } + + // If your cache client doesn't support cancellation tokens directly, + // you can use Task.WhenAny with a cancellation task + if (!cancellationToken.CanBeCanceled) + { + await cacheOperation.ConfigureAwait(false); } else { - _cacheClient.Db0.AddAsync(key, value).Wait(); + var cancellationTask = Task.Delay(Timeout.Infinite, cancellationToken); + var completedTask = await Task.WhenAny(cacheOperation, cancellationTask) + .ConfigureAwait(false); + + if (completedTask == cancellationTask) + { + cancellationToken.ThrowIfCancellationRequested(); + } + + await cacheOperation.ConfigureAwait(false); } } + catch (OperationCanceledException) + { + // Re-throw cancellation exceptions + throw; + } catch (Exception e) { if (ShouldPropagateExceptions) @@ -183,18 +222,92 @@ public void Set(T value, string key, TimeSpan? ttl = null) /// The value. /// The key. /// The sub key. - public void Set(T value, string key, string subKey) + /// To cancel operation. + /// /// + /// A ValueTask that represents the asynchronous hash field set operation. The task completes when: + /// - The hash field has been successfully updated in Redis, OR + /// - An exception occurs during the cache operation, OR + /// - The operation is cancelled via the cancellation token + /// The returned ValueTask has no result value (void equivalent for async operations). + /// + /// Completion scenarios: + /// • Success: Task completes successfully when hash field is updated + /// • Cancellation: Task throws OperationCanceledException if cancelled before or during execution + /// • Cache errors: Task may complete silently or throw exception based on ShouldPropagateExceptions setting + /// • Network errors: Task may complete silently or throw exception based on ShouldPropagateExceptions setting + /// + /// The ValueTask should always be awaited or its result checked to ensure proper exception handling. + /// + public async ValueTask SetAsync( + T value, + string key, + string subKey, + CancellationToken cancellationToken = default + ) { + if (string.IsNullOrEmpty(key)) + { + throw new ArgumentException("Key cannot be null or empty", nameof(key)); + } + + if (string.IsNullOrEmpty(subKey)) + { + throw new ArgumentException("SubKey cannot be null or empty", nameof(subKey)); + } + + if (EqualityComparer.Default.Equals(value, default(T)) && !typeof(T).IsValueType) + { + throw new ArgumentNullException(nameof(value)); + } + try { - var allValues = _cacheClient.Db0.ExistsAsync(key).Result - ? _cacheClient.Db0.HashGetAllAsync(key, CommandFlags.PreferReplica).Result - : new Dictionary(); + // Early cancellation check + cancellationToken.ThrowIfCancellationRequested(); + + // Check if the hash key exists + var keyExists = await _cacheClient.Db0.ExistsAsync(key).ConfigureAwait(false); + + // Get existing hash values if key exists, otherwise create new dictionary + IDictionary allValues; + if (keyExists) + { + // Check for cancellation before expensive operation + cancellationToken.ThrowIfCancellationRequested(); + + allValues = await _cacheClient + .Db0.HashGetAllAsync(key, CommandFlags.PreferReplica) + .ConfigureAwait(false); + } + else + { + allValues = new Dictionary(); + } + + // Update the specific sub-key with the new value allValues[subKey] = value; - _cacheClient.Db0.HashSetAsync(key, allValues, CommandFlags.FireAndForget).Wait(); + + // Check for cancellation before final write operation + cancellationToken.ThrowIfCancellationRequested(); + + // Save the updated hash back to Redis + await _cacheClient + .Db0.HashSetAsync(key, allValues, CommandFlags.FireAndForget) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Re-throw cancellation exceptions to preserve cancellation semantics + throw; + } + catch (ArgumentException) + { + // Re-throw argument validation exceptions + throw; } catch (Exception e) { + // Handle cache/network exceptions according to configuration if (ShouldPropagateExceptions) { throw; @@ -209,19 +322,64 @@ public void Set(T value, string key, string subKey) /// /// The type of object (the object will be cast to this type). /// The key. - /// T. + /// Token to monitor for cancellation requests. default if no cancellation is required; + /// otherwise, a that can be used to cancel the operation. + /// Thrown when key is null or empty. /// "Unable to get the item with key. - public T Get(string key) + /// Thrown when the operation is cancelled. + /// + /// A ValueTask that represents the asynchronous cache get operation. The task result contains: + /// - The cached value of type T if the key exists in cache + /// + /// The returned ValueTask will complete with the cached value when: + /// • Success: Key exists and value is successfully retrieved from cache + /// • Exception: Key doesn't exist in cache (throws InvalidOperationException) + /// • Exception: Cache operation fails and ShouldPropagateExceptions is true + /// • Exception: Cache operation fails and ShouldPropagateExceptions is false (throws InvalidOperationException after logging) + /// • Cancellation: Operation is cancelled via the cancellation token (throws OperationCanceledException) + /// + /// The ValueTask should always be awaited to ensure proper exception handling. + /// + public async ValueTask GetAsync(string key, CancellationToken cancellationToken = default) { + if (string.IsNullOrEmpty(key)) + { + throw new ArgumentException("Key cannot be null or empty", nameof(key)); + } + try { - if (_cacheClient.Db0.ExistsAsync(key).Result) + // Early cancellation check + cancellationToken.ThrowIfCancellationRequested(); + + // Check if the key exists in cache + var keyExists = await _cacheClient.Db0.ExistsAsync(key).ConfigureAwait(false); + + if (keyExists) { - return _cacheClient.Db0.GetAsync(key, CommandFlags.PreferReplica).Result; + // Check for cancellation before expensive get operation + cancellationToken.ThrowIfCancellationRequested(); + + // Retrieve the value from cache + var value = await _cacheClient + .Db0.GetAsync(key, CommandFlags.PreferReplica) + .ConfigureAwait(false); + return value; } } + catch (OperationCanceledException) + { + // Re-throw cancellation exceptions to preserve cancellation semantics + throw; + } + catch (ArgumentException) + { + // Re-throw argument validation exceptions + throw; + } catch (Exception e) { + // Handle cache/network exceptions according to configuration if (ShouldPropagateExceptions) { throw; @@ -230,6 +388,7 @@ public T Get(string key) HandleException(e); } + // Key doesn't exist or operation failed with exception suppression throw new InvalidOperationException( string.Format(CultureInfo.CurrentCulture, "Unable to get the item with key {0}", key) ); @@ -241,21 +400,68 @@ public T Get(string key) /// The type of the document. /// The key. /// The sub key. - /// T. + /// Token to monitor for cancellation requests. default if no cancellation is required; + /// otherwise, a that can be used to cancel the operation. + /// + /// A ValueTask that represents the asynchronous hash field get operation. The task result contains: + /// - The cached value of type T if the hash field exists in cache + /// + /// The returned ValueTask will complete with the cached value when: + /// • Success: Hash field exists and value is successfully retrieved from cache + /// • Exception: Hash field doesn't exist in cache (throws InvalidOperationException) + /// • Exception: Cache operation fails and ShouldPropagateExceptions is true + /// • Exception: Cache operation fails and ShouldPropagateExceptions is false (throws InvalidOperationException after logging) + /// • Cancellation: Operation is cancelled via the cancellation token (throws OperationCanceledException) + /// + /// The ValueTask should always be awaited to ensure proper exception handling. + /// + /// Thrown when key or subKey is null or empty. /// Unable to get the item with key and sub key. - public T Get(string key, string subKey) + /// Thrown when the operation is cancelled. + /// + public async ValueTask GetAsync( + string key, + string subKey, + CancellationToken cancellationToken = default + ) { + if (string.IsNullOrEmpty(key)) + { + throw new ArgumentException("Key cannot be null or empty", nameof(key)); + } + try { - if (_cacheClient.Db0.HashExistsAsync(key, subKey, CommandFlags.PreferReplica).Result) + // Early cancellation check + cancellationToken.ThrowIfCancellationRequested(); + + // Check if the key exists in cache + var keyExists = await _cacheClient.Db0.ExistsAsync(key).ConfigureAwait(false); + + if (keyExists) { - return _cacheClient - .Db0.HashGetAsync(key, subKey, CommandFlags.PreferReplica) - .Result; + // Check for cancellation before expensive get operation + cancellationToken.ThrowIfCancellationRequested(); + + // Retrieve the value from cache + return await _cacheClient + .Db0.GetAsync(key, CommandFlags.PreferReplica) + .ConfigureAwait(false); } } + catch (OperationCanceledException) + { + // Re-throw cancellation exceptions to preserve cancellation semantics + throw; + } + catch (ArgumentException) + { + // Re-throw argument validation exceptions + throw; + } catch (Exception e) { + // Handle cache/network exceptions according to configuration if (ShouldPropagateExceptions) { throw; @@ -264,13 +470,9 @@ public T Get(string key, string subKey) HandleException(e); } + // Key doesn't exist or operation failed with exception suppression throw new InvalidOperationException( - string.Format( - CultureInfo.CurrentCulture, - "Unable to get the item with key {0} and sub key {1}", - key, - subKey - ) + string.Format(CultureInfo.CurrentCulture, "Unable to get the item with key {0}", key) ); } @@ -280,69 +482,189 @@ public T Get(string key, string subKey) /// /// The type of object (the object will be cast to this type). /// The key. - /// The value. - /// Returns True if the object with the key exists, false otherwise. - public bool TryGet(string key, out T value) + /// Token to monitor for cancellation requests. default if no cancellation is required; + /// otherwise, a that can be used to cancel the operation. + /// + /// A ValueTask that represents the asynchronous try-get operation. The task result contains: + /// - A touple with Success=true and the cached value if the key exists + /// - A touple with Success=false and default(T) if the key doesn't exist or operation fails + /// + /// The returned ValueTask will complete when: + /// • Success: Key exists and value is successfully retrieved from cache + /// • Success: Key doesn't exist (returns false with default value) + /// • Exception: Cache operation fails and ShouldPropagateExceptions is true + /// • Exception: Operation is cancelled via the cancellation token (throws OperationCanceledException) + /// + /// When ShouldPropagateExceptions is false, cache errors are logged and the method returns false. + /// + /// Thrown when key is null or empty. + /// Thrown when the operation is cancelled. + public async ValueTask<(bool Success, T Value)> TryGetAsync( + string key, + CancellationToken cancellationToken = default + ) { - value = default; + if (string.IsNullOrEmpty(key)) + { + throw new ArgumentException("Key cannot be null or empty", nameof(key)); + } + try { - value = _cacheClient.Db0.GetAsync(key, CommandFlags.PreferReplica).Result; - return _cacheClient.Db0.ExistsAsync(key).Result; + // Early cancellation check + cancellationToken.ThrowIfCancellationRequested(); + + // More efficient: Check existence first, then get value if it exists + var keyExists = await _cacheClient.Db0.ExistsAsync(key).ConfigureAwait(false); + + if (keyExists) + { + // Check for cancellation before expensive get operation + cancellationToken.ThrowIfCancellationRequested(); + + // Retrieve the value from cache + var value = await _cacheClient + .Db0.GetAsync(key, CommandFlags.PreferReplica) + .ConfigureAwait(false); + return (Success: true, Value: value); + } + + // Key doesn't exist + return (Success: false, Value: default(T)); + } + catch (OperationCanceledException) + { + // Re-throw cancellation exceptions to preserve cancellation semantics + throw; + } + catch (ArgumentException) + { + // Re-throw argument validation exceptions + throw; } catch (Exception e) { + // Handle cache/network exceptions according to configuration if (ShouldPropagateExceptions) { throw; } HandleException(e); - } - return false; + // Return failed result when exceptions are suppressed + return (Success: false, Value: default(T)); + } } /// - /// Tries the get. + /// Asynchronously attempts to get a value from the cache using the specified key. + /// This method returns both the success status and the value in a single result. /// - /// The type of the document. - /// The key. - /// The sub key. - /// The value. - /// true if get the key, false otherwise. - public bool TryGet(string key, string subKey, out T value) + /// The type of value to retrieve from cache + /// The cache key used to identify the cached value. Cannot be null or empty. + /// The subkey. + /// Token to monitor for cancellation requests. default if no cancellation is required; + /// otherwise, a that can be used to cancel the operation. + /// + /// A ValueTask that represents the asynchronous try-get operation. The task result contains: + /// - A touple with Success=true and the cached value if the key exists + /// - A touple with Success=false and default(T) if the key doesn't exist or operation fails + /// + /// The returned ValueTask will complete when: + /// • Success: Key exists and value is successfully retrieved from cache + /// • Success: Key doesn't exist (returns false with default value) + /// • Exception: Cache operation fails and ShouldPropagateExceptions is true + /// • Exception: Operation is cancelled via the cancellation token (throws OperationCanceledException) + /// + /// When ShouldPropagateExceptions is false, cache errors are logged and the method returns false. + /// + /// Thrown when key is null or empty. + /// Thrown when the operation is cancelled. + public async ValueTask<(bool Success, T Value)> TryGetAsync( + string key, + string subKey, + CancellationToken cancellationToken = default + ) { - value = default; + if (string.IsNullOrEmpty(key)) + { + throw new ArgumentException("Key cannot be null or empty", nameof(key)); + } + try { - value = _cacheClient - .Db0.HashGetAsync(key, subKey, CommandFlags.PreferReplica) - .Result; - return _cacheClient.Db0.HashExistsAsync(key, subKey).Result; + // Early cancellation check + cancellationToken.ThrowIfCancellationRequested(); + + // More efficient: Check existence first, then get value if it exists + var hashFieldExists = await _cacheClient + .Db0.HashExistsAsync(key, subKey) + .ConfigureAwait(false); + + if (hashFieldExists) + { + // Check for cancellation before expensive get operation + cancellationToken.ThrowIfCancellationRequested(); + + // Retrieve the value from cache + var value = await _cacheClient + .Db0.HashGetAsync(key, subKey, CommandFlags.PreferReplica) + .ConfigureAwait(false); + return (Success: true, Value: value); + } + + // Key doesn't exist + return (Success: false, Value: default(T)); + } + catch (OperationCanceledException) + { + // Re-throw cancellation exceptions to preserve cancellation semantics + throw; + } + catch (ArgumentException) + { + // Re-throw argument validation exceptions + throw; } catch (Exception e) { + // Handle cache/network exceptions according to configuration if (ShouldPropagateExceptions) { throw; } HandleException(e); - } - return false; + // Return failed result when exceptions are suppressed + return (Success: false, Value: default(T)); + } } /// /// Removes the specified key from the cache. /// /// The key. - public void Remove(string key) + /// Token to monitor for cancellation requests. default if no cancellation is required; + /// otherwise, a that can be used to cancel the operation. + /// A ValueTask that represents the asynchronous call. + /// + /// Thrown when the operation is cancelled. + public async Task RemoveAsync(string key, CancellationToken cancellationToken = default) { try { - _cacheClient.Db0.RemoveAsync(key).Wait(); + cancellationToken.ThrowIfCancellationRequested(); + + await _cacheClient + .Db0.RemoveAsync(key, CommandFlags.FireAndForget) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Re-throw cancellation exceptions to preserve cancellation semantics + throw; } catch (Exception e) { @@ -356,15 +678,30 @@ public void Remove(string key) } /// - /// Removes the specified key. + /// Removes the specified key from the cache. /// /// The key. - /// The sub key. - public void Remove(string key, string subKey) + /// The subkey. + /// Token to monitor for cancellation requests. default if no cancellation is required; + /// otherwise, a that can be used to cancel the operation. + /// A ValueTask that represents the asynchronous call. + /// Thrown when the operation is cancelled. + public async Task RemoveAsync( + string key, + string subKey, + CancellationToken cancellationToken = default + ) { try { - _cacheClient.Db0.HashDeleteAsync(key, subKey, CommandFlags.FireAndForget).Wait(); + await _cacheClient + .Db0.HashDeleteAsync(key, subKey, CommandFlags.FireAndForget) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Re-throw cancellation exceptions to preserve cancellation semantics + throw; } catch (Exception e) { @@ -381,13 +718,25 @@ public void Remove(string key, string subKey) /// Returns the time to live of the specified key. /// /// The key. + /// Tp cancel operation. + /// A representing the asynchronous operation. /// The timespan until this key is expired from the cache or 0 if it's already expired or doesn't exists. - public TimeSpan TTL(string key) + /// Thrown when the operation is cancelled. + public async Task TTLAsync(string key, CancellationToken cancellationToken = default) { try { - return _cacheClient.Db0.Database.KeyTimeToLive(key, CommandFlags.PreferReplica) - ?? TimeSpan.Zero; + cancellationToken.ThrowIfCancellationRequested(); + var ttl = await _cacheClient.Db0.Database.KeyTimeToLiveAsync( + key, + CommandFlags.PreferReplica + ); + return ttl ?? TimeSpan.Zero; + } + catch (OperationCanceledException) + { + // Re-throw cancellation exceptions to preserve cancellation semantics + throw; } catch (Exception e) { @@ -405,11 +754,12 @@ public TimeSpan TTL(string key) /// /// Clears this instance. /// - public void Clear() + /// A representing the asynchronous operation. + public async Task Clear() { try { - _cacheClient.Db0.FlushDbAsync().Wait(); + await _cacheClient.Db0.FlushDbAsync(); } catch (Exception e) { diff --git a/Src/CrispyWaffle.Utils/Communications/SmtpMailer.cs b/Src/CrispyWaffle.Utils/Communications/SmtpMailer.cs index 8c26221c..b10b2cdb 100644 --- a/Src/CrispyWaffle.Utils/Communications/SmtpMailer.cs +++ b/Src/CrispyWaffle.Utils/Communications/SmtpMailer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -8,6 +8,7 @@ using System.Net.Mime; using System.Net.Sockets; using System.Text; +using System.Threading; using System.Threading.Tasks; using CrispyWaffle.Cache; using CrispyWaffle.Configuration; @@ -329,7 +330,8 @@ public async Task SendAsync() } catch (Exception e) { - if (!HandleExtension(e, cacheKey)) + var result = await HandleExtension(e, cacheKey); + if (!result) { throw; } @@ -343,7 +345,9 @@ public async Task SendAsync() /// A representing the asynchronous operation. private async Task SendInternalAsync(string cacheKey) { - if (CacheManager.TryGet(cacheKey, out bool exists) && exists) + (bool exists, _) = await CacheManager.TryGetAsync(cacheKey, CancellationToken.None); + + if (exists) { LogConsumer.Trace("E-mail sending disabled due {0}", "network error"); return; @@ -366,8 +370,8 @@ private async Task SendInternalAsync(string cacheKey) /// /// The exception that occurred. /// The cache key to prevent repeated failures. - /// if the exception was handled; otherwise, . - private static bool HandleExtension(Exception e, string cacheKey) + /// A representing the asynchronous operation. + private static async Task HandleExtension(Exception e, string cacheKey) { TelemetryAnalytics.TrackMetric("SMTPError", e.Message); if ( @@ -376,7 +380,7 @@ private static bool HandleExtension(Exception e, string cacheKey) || e.Message.IndexOf(@"5.0.3", StringComparison.InvariantCultureIgnoreCase) != -1 ) { - CacheManager.Set(true, cacheKey, new TimeSpan(0, 15, 0)); + await CacheManager.SetAsync(true, cacheKey, new TimeSpan(0, 15, 0)); return true; } diff --git a/Src/CrispyWaffle/Cache/CacheManager.cs b/Src/CrispyWaffle/Cache/CacheManager.cs index 15878fbd..9accf7d0 100644 --- a/Src/CrispyWaffle/Cache/CacheManager.cs +++ b/Src/CrispyWaffle/Cache/CacheManager.cs @@ -1,9 +1,15 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics; using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; using CrispyWaffle.Composition; using CrispyWaffle.Log; +using Newtonsoft.Json.Linq; namespace CrispyWaffle.Cache; @@ -32,6 +38,11 @@ public static class CacheManager /// private static bool _isMemoryRepositoryInList; + /// + /// Default timeout. + /// + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10); + /// /// Adds a new cache repository of the specified type with an automatically assigned priority. /// @@ -106,13 +117,52 @@ public static int AddRepository(ICacheRepository repository, int priority) /// The type of the value to cache. /// The value to cache. /// The key under which to store the value. - public static void Set(T value, [Localizable(false)] string key) + /// Cancel. + /// If cancelled. + /// void. + public static async ValueTask SetAsync( + T value, + [Localizable(false)] string key, + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + LogConsumer.Trace("Adding {0} to {1} cache repositories", key, _repositories.Count); - foreach (var repository in _repositories.Values) + + var tasks = _repositories.Values.Select(async repository => { - repository.Set(value, key); - } + try + { + await repository.SetAsync(value, key, cancellationToken: cancellationToken); + return true; + } + catch (OperationCanceledException) + { + Console.WriteLine("Cache operation was cancelled"); + return false; + } + catch (Exception ex) + { + LogConsumer.Error( + "Failed to set {0} in repository {1}: {2}", + key, + repository.GetType().Name, + ex.Message + ); + return false; + } + }); + + var results = await Task.WhenAll(tasks); + var successCount = results.Count(r => r); + + LogConsumer.Info( + "Successfully set {0} in {1} out of {2} repositories", + key, + successCount, + _repositories.Count + ); } /// @@ -122,22 +172,58 @@ public static void Set(T value, [Localizable(false)] string key) /// The value to cache. /// The key under which to store the value. /// The sub key for additional categorization. - public static void Set( + /// Cancel. + /// If cancelled. + /// void. + public static async ValueTask SetAsync( T value, [Localizable(false)] string key, - [Localizable(false)] string subKey + [Localizable(false)] string subKey, + CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + LogConsumer.Trace( "Adding {0}/{2} to {1} cache repositories", key, _repositories.Count, subKey ); - foreach (var repository in _repositories.Values) + + var tasks = _repositories.Values.Select(async repository => { - repository.Set(value, key, subKey); - } + try + { + await repository.SetAsync(value, key, subKey, cancellationToken: cancellationToken); + return true; + } + catch (OperationCanceledException) + { + Console.WriteLine("Cache operation was cancelled"); + return false; + } + catch (Exception ex) + { + LogConsumer.Error( + "Failed to set {0} in repository {1}: {2}", + key, + repository.GetType().Name, + ex.Message + ); + return false; + } + }); + + var results = await Task.WhenAll(tasks); + var successCount = results.Count(r => r); + + LogConsumer.Info( + "Successfully set {0} in {1} out of {2} repositories", + key, + successCount, + _repositories.Count + ); } /// @@ -147,18 +233,57 @@ public static void Set( /// The value to cache. /// The key under which to store the value. /// The time-to-live for the cached value. - public static void Set(T value, [Localizable(false)] string key, TimeSpan ttl) + /// Cancel. + /// If cancelled. + /// void. + public static async ValueTask SetAsync( + T value, + [Localizable(false)] string key, + TimeSpan ttl, + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + LogConsumer.Trace( "Adding {0} to {1} cache repositories with TTL of {2:g}", key, _repositories.Count, ttl ); - foreach (var repository in _repositories.Values) + var tasks = _repositories.Values.Select(async repository => { - repository.Set(value, key, ttl); - } + try + { + await repository.SetAsync(value, key, ttl, cancellationToken: cancellationToken); + return true; + } + catch (OperationCanceledException) + { + Console.WriteLine("Cache operation was cancelled"); + return false; + } + catch (Exception ex) + { + LogConsumer.Error( + "Failed to set {0} in repository {1}: {2}", + key, + repository.GetType().Name, + ex.Message + ); + return false; + } + }); + + var results = await Task.WhenAll(tasks); + var successCount = results.Count(r => r); + + LogConsumer.Info( + "Successfully set {0} in {1} out of {2} repositories", + key, + successCount, + _repositories.Count + ); } /// @@ -168,12 +293,18 @@ public static void Set(T value, [Localizable(false)] string key, TimeSpan ttl /// The type of the value to cache. /// The value to cache. /// The key under which to store the value. + /// Cancel. /// The repository of type {type.FullName} isn't available in the repositories providers list. - public static void SetTo( + /// If cancelled. + /// void. + public static async ValueTask SetToAsync( TValue value, - [Localizable(false)] string key + [Localizable(false)] string key, + CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + var type = typeof(TCacheRepository); LogConsumer.Trace("Adding {0} to repository of type {1}", key, type.FullName); var repository = _repositories.SingleOrDefault(r => type == r.Value.GetType()).Value; @@ -184,7 +315,23 @@ public static void SetTo( ); } - repository.Set(value, key); + try + { + await repository.SetAsync(value, key, cancellationToken: cancellationToken); + } + catch (OperationCanceledException) + { + Console.WriteLine("Cache operation was cancelled"); + } + catch (Exception ex) + { + LogConsumer.Error( + "Failed to set {0} in repository {1}: {2}", + key, + repository.GetType().Name, + ex.Message + ); + } } /// @@ -195,13 +342,19 @@ public static void SetTo( /// The value to cache. /// The key under which to store the value. /// The sub key for additional categorization. + /// Cancel. /// The repository of type {type.FullName} isn't available in the repositories providers list. - public static void SetTo( + /// If cancelled. + /// void. + public static async ValueTask SetToAsync( TValue value, [Localizable(false)] string key, - [Localizable(false)] string subKey + [Localizable(false)] string subKey, + CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + var type = typeof(TCacheRepository); LogConsumer.Trace("Adding {0}/{2} to repository of type {1}", key, type.FullName, subKey); var repository = _repositories.SingleOrDefault(r => type == r.Value.GetType()).Value; @@ -212,7 +365,23 @@ public static void SetTo( ); } - repository.Set(value, key, subKey); + try + { + await repository.SetAsync(value, key, subKey, cancellationToken: cancellationToken); + } + catch (OperationCanceledException) + { + Console.WriteLine("Cache operation was cancelled"); + } + catch (Exception ex) + { + LogConsumer.Error( + "Failed to set {0} in repository {1}: {2}", + key, + repository.GetType().Name, + ex.Message + ); + } } /// @@ -223,13 +392,19 @@ public static void SetTo( /// The value to cache. /// The key under which to store the value. /// The time-to-live for this key. + /// Cancel. /// The repository of type {type.FullName} isn't available in the repositories providers list. - public static void SetTo( + /// If cancelled. + /// void. + public static async ValueTask SetToAsync( TValue value, [Localizable(false)] string key, - TimeSpan ttl + TimeSpan ttl, + CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + var type = typeof(TCacheRepository); LogConsumer.Trace( "Adding {0} to repository of type {1} with TTL of {2:g}", @@ -245,7 +420,23 @@ TimeSpan ttl ); } - repository.Set(value, key, ttl); + try + { + await repository.SetAsync(value, key, ttl, cancellationToken: cancellationToken); + } + catch (OperationCanceledException) + { + Console.WriteLine("Cache operation was cancelled"); + } + catch (Exception ex) + { + LogConsumer.Error( + "Failed to set {0} in repository {1}: {2}", + key, + repository.GetType().Name, + ex.Message + ); + } } /// @@ -257,14 +448,20 @@ TimeSpan ttl /// The key under which to store the value. /// The sub key of the cached value. /// The time-to-live for this key. + /// Cancel. /// The repository of type {type.FullName} isn't available in the repositories providers list. - public static void SetTo( + /// If cancelled. + /// void. + public static async ValueTask SetToAsync( TValue value, [Localizable(false)] string key, [Localizable(false)] string subKey, - TimeSpan ttl + TimeSpan ttl, + CancellationToken cancellationToken = default ) { + cancellationToken.ThrowIfCancellationRequested(); + var type = typeof(TCacheRepository); LogConsumer.Trace( "Adding {0}/{2} to repository of type {1} with TTL of {2:g}", @@ -281,7 +478,23 @@ TimeSpan ttl ); } - repository.Set(value, key, subKey); + try + { + await repository.SetAsync(value, key, subKey, cancellationToken: cancellationToken); + } + catch (OperationCanceledException) + { + Console.WriteLine("Cache operation was cancelled"); + } + catch (Exception ex) + { + LogConsumer.Error( + "Failed to set {0} in repository {1}: {2}", + key, + repository.GetType().Name, + ex.Message + ); + } } /// @@ -289,28 +502,130 @@ TimeSpan ttl /// /// The type of the value to retrieve. /// The key of the cached value. + /// Cancellation token. /// The retrieved value. /// Unable to get the item with key {key}. - public static T Get([Localizable(false)] string key) + /// Operation cancelled. + public static async ValueTask GetAsync( + [Localizable(false)] string key, + CancellationToken cancellationToken = default + ) { - LogConsumer.Trace( - "Getting {0} from any of {1} cache repositories", - key, - _repositories.Count - ); - foreach (var repository in _repositories.Values) + try { - if (!repository.TryGet(key, out T value)) - { - continue; - } + cancellationToken.ThrowIfCancellationRequested(); + + LogConsumer.Trace( + "Getting {0} from any of {1} cache repositories", + key, + _repositories.Count + ); + + // Create timeout for the entire operation + using var timeoutCts = new CancellationTokenSource(DefaultTimeout); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCts.Token + ); + + var errors = new List<(string Repository, Exception Error)>(); - if (_isMemoryRepositoryInList && repository.GetType() != typeof(MemoryCacheRepository)) + foreach (var repository in _repositories.Values) { - SetTo(value, key); - } + try + { + combinedCts.Token.ThrowIfCancellationRequested(); + + var repositoryName = repository.GetType().Name; + var repositoryStopwatch = Stopwatch.StartNew(); + + LogConsumer.Debug( + "Attempting to get key {0} from repository {1}", + key, + repositoryName + ); + + // Try to get value from repository + var (success, result) = await repository.TryGetAsync(key, combinedCts.Token); + + repositoryStopwatch.Stop(); - return value; + if (!success) + { + LogConsumer.Debug( + "Key {0} not found in repository {1} ({2}ms)", + key, + repositoryName, + repositoryStopwatch.ElapsedMilliseconds + ); + continue; + } + + LogConsumer.Info("Found key {0} in repository {1}", key, repositoryName); + + // If found in non-memory repository, promote to memory cache + if ( + _isMemoryRepositoryInList + && repository.GetType() != typeof(MemoryCacheRepository) + ) + { + _ = Task.Run( + async () => + { + try + { + await SetToAsync( + result, + key, + CancellationToken.None + ); + LogConsumer.Debug("Promoted key {0} to memory cache", key); + } + catch (Exception ex) + { + LogConsumer.Error( + "Failed to promote key {0} to memory cache: {1}", + key, + ex.Message + ); + } + }, + cancellationToken + ); + } + + return result; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // User cancelled - stop immediately + LogConsumer.Info("Get operation cancelled by user for key {0}", key); + throw; + } + catch (OperationCanceledException) when (timeoutCts.Token.IsCancellationRequested) + { + // Timeout occurred + LogConsumer.Error("Get operation timed out for key {0}", key); + throw new TimeoutException($"Get operation timed out for key {key}"); + } + catch (Exception ex) + { + var repositoryName = repository.GetType().Name; + errors.Add((repositoryName, ex)); + + LogConsumer.Error( + "Error getting key {0} from repository {1}: {2}", + key, + repositoryName, + ex.Message + ); + } + } + } + catch (OperationCanceledException) + { + Console.WriteLine("Cache operation was cancelled"); + throw; } throw new InvalidOperationException($"Unable to get the item with key {key}"); @@ -324,32 +639,136 @@ public static T Get([Localizable(false)] string key) /// The sub key of the cached value. /// The retrieved value. /// Unable to get the item with key {key} and sub key {subKey}. - public static T Get([Localizable(false)] string key, [Localizable(false)] string subKey) + public static async ValueTask GetAsync( + [Localizable(false)] string key, + [Localizable(false)] string subKey, + CancellationToken cancellationToken = default + ) { - LogConsumer.Trace( - "Getting {0}/{2} from any of {1} cache repositories", - key, - _repositories.Count, - subKey - ); - foreach (var repository in _repositories.Values) + try { - if (!repository.TryGet(key, subKey, out T value)) - { - continue; - } + cancellationToken.ThrowIfCancellationRequested(); + + LogConsumer.Trace( + "Getting {0}/{2} from any of {1} cache repositories", + key, + _repositories.Count, + subKey + ); + + // Create timeout for the entire operation + using var timeoutCts = new CancellationTokenSource(DefaultTimeout); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCts.Token + ); + + var errors = new List<(string Repository, Exception Error)>(); - if (_isMemoryRepositoryInList && repository.GetType() != typeof(MemoryCacheRepository)) + foreach (var repository in _repositories.Values) { - SetTo(value, key, subKey); - } + try + { + combinedCts.Token.ThrowIfCancellationRequested(); + + var repositoryName = repository.GetType().Name; + var repositoryStopwatch = Stopwatch.StartNew(); + + LogConsumer.Debug( + "Attempting to get key {0} from repository {1}", + key, + repositoryName + ); + + // Try to get value from repository + var (success, result) = await repository.TryGetAsync( + key, + subKey, + combinedCts.Token + ); + + repositoryStopwatch.Stop(); + + if (!success) + { + LogConsumer.Debug( + "Key {0} not found in repository {1} ({2}ms)", + key, + repositoryName, + repositoryStopwatch.ElapsedMilliseconds + ); + continue; + } + + LogConsumer.Info("Found key {0} in repository {1}", key, repositoryName); + + // If found in non-memory repository, promote to memory cache + if ( + _isMemoryRepositoryInList + && repository.GetType() != typeof(MemoryCacheRepository) + ) + { + _ = Task.Run( + async () => + { + try + { + await SetToAsync( + result, + key, + subKey, + CancellationToken.None + ); + LogConsumer.Debug("Promoted key {0} to memory cache", key); + } + catch (Exception ex) + { + LogConsumer.Error( + "Failed to promote key {0} to memory cache: {1}", + key, + ex.Message + ); + } + }, + cancellationToken + ); + } + + return result; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // User cancelled - stop immediately + LogConsumer.Info("Get operation cancelled by user for key {0}", key); + throw; + } + catch (OperationCanceledException) when (timeoutCts.Token.IsCancellationRequested) + { + // Timeout occurred + LogConsumer.Error("Get operation timed out for key {0}", key); + throw new TimeoutException($"Get operation timed out for key {key}"); + } + catch (Exception ex) + { + var repositoryName = repository.GetType().Name; + errors.Add((repositoryName, ex)); - return value; + LogConsumer.Error( + "Error getting key {0} from repository {1}: {2}", + key, + repositoryName, + ex.Message + ); + } + } + } + catch (OperationCanceledException) + { + Console.WriteLine("Cache operation was cancelled"); + throw; } - throw new InvalidOperationException( - $"Unable to get the item with key {key} and sub key {subKey}" - ); + throw new InvalidOperationException($"Unable to get the item with key {key}"); } /// @@ -358,21 +777,56 @@ public static T Get([Localizable(false)] string key, [Localizable(false)] str /// The type of the cache repository to retrieve the value from. /// The type of the value to retrieve. /// The key of the cached value. + /// Cancelation token. /// The retrieved value. /// The repository of type {type.FullName} isn't available in the repositories providers list. - public static TValue GetFrom([Localizable(false)] string key) + public static async ValueTask GetFrom( + [Localizable(false)] string key, + CancellationToken cancellationToken = default + ) { - var type = typeof(TCacheRepository); - LogConsumer.Trace("Getting {0} from repository {1}", key, type.FullName); - var repository = _repositories.SingleOrDefault(r => type == r.Value.GetType()).Value; - if (repository == null) + try { - throw new InvalidOperationException( - $"The repository of type {type.FullName} isn't available in the repositories providers list" + cancellationToken.ThrowIfCancellationRequested(); + + var type = typeof(TCacheRepository); + + LogConsumer.Trace("Getting {0} from repository {1}", key, type.FullName); + var repository = _repositories.SingleOrDefault(r => type == r.Value.GetType()).Value; + if (repository == null) + { + throw new InvalidOperationException( + $"The repository of type {type.FullName} isn't available in the repositories providers list" + ); + } + + // Create timeout for the operation + using var timeoutCts = new CancellationTokenSource(DefaultTimeout); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCts.Token ); - } - return repository.Get(key); + var result = await repository.GetAsync(key, combinedCts.Token); + + return result; + } + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) + { + LogConsumer.Info( + "Get operation cancelled by user for key {0} from {1}", + key, + typeof(TCacheRepository).Name + ); + throw; + } + catch (OperationCanceledException) + { + var error = + $"Get operation timed out for key {key} from {typeof(TCacheRepository).Name}"; + LogConsumer.Error(error); + throw new TimeoutException(error); + } } /// @@ -382,24 +836,58 @@ public static TValue GetFrom([Localizable(false)] stri /// The type of the value to retrieve. /// The key of the cached value. /// The sub key of the cached value. + /// Cancelation token. /// The retrieved value. /// The repository of type {type.FullName} isn't available in the repositories providers list. - public static TValue GetFrom( + /// Operation cancelled. + public static async ValueTask GetFromAsync( [Localizable(false)] string key, - [Localizable(false)] string subKey + [Localizable(false)] string subKey, + CancellationToken cancellationToken = default ) { - var type = typeof(TCacheRepository); - LogConsumer.Trace("Getting {0}/{2} from repository {1}", key, type.FullName, subKey); - var repository = _repositories.SingleOrDefault(r => type == r.Value.GetType()).Value; - if (repository == null) + try { - throw new InvalidOperationException( - $"The repository of type {type.FullName} isn't available in the repositories providers list" + cancellationToken.ThrowIfCancellationRequested(); + + var type = typeof(TCacheRepository); + + LogConsumer.Trace("Getting {0} from repository {1}", key, type.FullName); + var repository = _repositories.SingleOrDefault(r => type == r.Value.GetType()).Value; + if (repository == null) + { + throw new InvalidOperationException( + $"The repository of type {type.FullName} isn't available in the repositories providers list" + ); + } + + // Create timeout for the operation + using var timeoutCts = new CancellationTokenSource(DefaultTimeout); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCts.Token ); - } - return repository.Get(key, subKey); + var result = await repository.GetAsync(key, subKey, combinedCts.Token); + + return result; + } + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) + { + LogConsumer.Info( + "Get operation cancelled by user for key {0} from {1}", + key, + typeof(TCacheRepository).Name + ); + throw; + } + catch (OperationCanceledException) + { + var error = + $"Get operation timed out for key {key} from {typeof(TCacheRepository).Name}"; + LogConsumer.Error(error); + throw new TimeoutException(error); + } } /// @@ -407,32 +895,134 @@ public static TValue GetFrom( /// /// The type of the value to retrieve. /// The key of the cached value. - /// The retrieved value, if found. - /// true if the value was found; otherwise, false. - public static bool TryGet([Localizable(false)] string key, out T value) + /// Cancelation token. + /// Operation cancelled. + /// + /// A tuple containing Success (true if found and castable to T) and Value (the cached item or default(T)). + /// + public static async ValueTask<(bool Success, T Value)> TryGetAsync( + [Localizable(false)] string key, + CancellationToken cancellationToken = default + ) { - LogConsumer.Trace( - "Trying to get {0} from any of {1} repositories", - key, - _repositories.Count - ); - value = default; - foreach (var repository in _repositories.Values) + try { - if (!repository.TryGet(key, out value)) - { - continue; - } + cancellationToken.ThrowIfCancellationRequested(); + + LogConsumer.Trace( + "Trying to get {0} from any of {1} repositories", + key, + _repositories.Count + ); + + // Create timeout for the entire operation + using var timeoutCts = new CancellationTokenSource(DefaultTimeout); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCts.Token + ); - if (_isMemoryRepositoryInList && repository.GetType() != typeof(MemoryCacheRepository)) + var errors = new List<(string Repository, Exception Error)>(); + + foreach (var repository in _repositories.Values) { - SetTo(value, key); - } + try + { + combinedCts.Token.ThrowIfCancellationRequested(); + + var repositoryName = repository.GetType().Name; + var repositoryStopwatch = Stopwatch.StartNew(); + + LogConsumer.Debug( + "Attempting to get key {0} from repository {1}", + key, + repositoryName + ); + + // Try to get value from repository + var (success, result) = await repository.TryGetAsync(key, combinedCts.Token); + + repositoryStopwatch.Stop(); + + if (!success) + { + LogConsumer.Debug( + "Key {0} not found in repository {1} ({2}ms)", + key, + repositoryName, + repositoryStopwatch.ElapsedMilliseconds + ); + continue; + } + + LogConsumer.Info("Found key {0} in repository {1}", key, repositoryName); + + // If found in non-memory repository, promote to memory cache + if ( + _isMemoryRepositoryInList + && repository.GetType() != typeof(MemoryCacheRepository) + ) + { + _ = Task.Run( + async () => + { + try + { + await SetToAsync( + result, + key, + CancellationToken.None + ); + LogConsumer.Debug("Promoted key {0} to memory cache", key); + } + catch (Exception ex) + { + LogConsumer.Error( + "Failed to promote key {0} to memory cache: {1}", + key, + ex.Message + ); + } + }, + cancellationToken + ); + } + + return (true, result); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // User cancelled - stop immediately + LogConsumer.Info("Get operation cancelled by user for key {0}", key); + throw; + } + catch (OperationCanceledException) when (timeoutCts.Token.IsCancellationRequested) + { + // Timeout occurred + LogConsumer.Error("Get operation timed out for key {0}", key); + throw new TimeoutException($"Get operation timed out for key {key}"); + } + catch (Exception ex) + { + var repositoryName = repository.GetType().Name; + errors.Add((repositoryName, ex)); - return true; + LogConsumer.Error( + "Error getting key {0} from repository {1}: {2}", + key, + repositoryName, + ex.Message + ); + } + } + } + catch (OperationCanceledException) + { + Console.WriteLine("Cache operation was cancelled"); + throw; } - return false; + return (false, default(T)); } /// @@ -441,34 +1031,135 @@ public static bool TryGet([Localizable(false)] string key, out T value) /// The type of the value to retrieve. /// The key of the cached value. /// The sub key of the cached value. - /// The retrieved value, if found. + /// Cancel token. /// true if the value was found; otherwise, false. - public static bool TryGet( + public static async ValueTask TryGetAsync( [Localizable(false)] string key, [Localizable(false)] string subKey, - out T value + CancellationToken cancellationToken = default ) { - LogConsumer.Trace( - "Trying to get {0}/{2} from any of {1} repositories", - key, - _repositories.Count, - subKey - ); - value = default; - foreach (var repository in _repositories.Values) + try { - if (!repository.TryGet(key, subKey, out value)) - { - continue; - } + cancellationToken.ThrowIfCancellationRequested(); + + LogConsumer.Trace( + "Trying to get {0}/{2} from any of {1} repositories", + key, + _repositories.Count, + subKey + ); + + // Create timeout for the entire operation + using var timeoutCts = new CancellationTokenSource(DefaultTimeout); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCts.Token + ); - if (_isMemoryRepositoryInList && repository.GetType() != typeof(MemoryCacheRepository)) + var errors = new List<(string Repository, Exception Error)>(); + + foreach (var repository in _repositories.Values) { - SetTo(value, key, subKey); - } + try + { + combinedCts.Token.ThrowIfCancellationRequested(); - return true; + var repositoryName = repository.GetType().Name; + var repositoryStopwatch = Stopwatch.StartNew(); + + LogConsumer.Debug( + "Attempting to get key {0} from repository {1}", + key, + repositoryName + ); + + // Try to get value from repository + var (success, result) = await repository.TryGetAsync( + key, + subKey, + combinedCts.Token + ); + + repositoryStopwatch.Stop(); + + if (!success) + { + LogConsumer.Debug( + "Key {0} not found in repository {1} ({2}ms)", + key, + repositoryName, + repositoryStopwatch.ElapsedMilliseconds + ); + continue; + } + + LogConsumer.Info("Found key {0} in repository {1}", key, repositoryName); + + // If found in non-memory repository, promote to memory cache + if ( + _isMemoryRepositoryInList + && repository.GetType() != typeof(MemoryCacheRepository) + ) + { + _ = Task.Run( + async () => + { + try + { + await SetToAsync( + result, + key, + subKey, + CancellationToken.None + ); + LogConsumer.Debug("Promoted key {0} to memory cache", key); + } + catch (Exception ex) + { + LogConsumer.Error( + "Failed to promote key {0} to memory cache: {1}", + key, + ex.Message + ); + } + }, + cancellationToken + ); + } + + return true; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // User cancelled - stop immediately + LogConsumer.Info("Get operation cancelled by user for key {0}", key); + throw; + } + catch (OperationCanceledException) when (timeoutCts.Token.IsCancellationRequested) + { + // Timeout occurred + LogConsumer.Error("Get operation timed out for key {0}", key); + throw new TimeoutException($"Get operation timed out for key {key}"); + } + catch (Exception ex) + { + var repositoryName = repository.GetType().Name; + errors.Add((repositoryName, ex)); + + LogConsumer.Error( + "Error getting key {0} from repository {1}: {2}", + key, + repositoryName, + ex.Message + ); + } + } + } + catch (OperationCanceledException) + { + Console.WriteLine("Cache operation was cancelled"); + throw; } return false; @@ -479,17 +1170,17 @@ out T value /// /// The key of the cached value. /// The TTL of the cached value, or TimeSpan.Zero if not found. - public static TimeSpan TTL([Localizable(false)] string key) + public static async Task TTLAsync([Localizable(false)] string key) { LogConsumer.Trace( "Trying to get TTL of key {0} from {1} repositories", key, _repositories.Count ); - var result = new TimeSpan(0); + TimeSpan result = new TimeSpan(0); foreach (var repository in _repositories.Values) { - var currentResult = repository.TTL(key); + TimeSpan currentResult = await repository.TTLAsync(key); if (currentResult == result) { continue; @@ -498,19 +1189,75 @@ public static TimeSpan TTL([Localizable(false)] string key) return currentResult; } - return new TimeSpan(0); + return result; // Default TTL if not found in any repository } /// /// Removes a cached value by key from all repositories. /// /// The key of the cached value to remove. - public static void Remove([Localizable(false)] string key) + /// Cancel token. + /// Completed task. + public static async ValueTask Remove( + [Localizable(false)] string key, + CancellationToken cancellationToken = default + ) { - LogConsumer.Trace("Removing key {0} from {1} repositories", key, _repositories.Count); - foreach (var repository in _repositories.Values) + try { - repository.Remove(key); + cancellationToken.ThrowIfCancellationRequested(); + + LogConsumer.Trace("Removing key {0} from {1} repositories", key, _repositories.Count); + + // Create timeout for the entire operation + using var timeoutCts = new CancellationTokenSource(DefaultTimeout); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCts.Token + ); + + var errors = new List<(string Repository, Exception Error)>(); + + foreach (var repository in _repositories.Values) + { + try + { + combinedCts.Token.ThrowIfCancellationRequested(); + + var repositoryName = repository.GetType().Name; + + LogConsumer.Debug( + "Attempting to delete key {0} from repository {1}", + key, + repositoryName + ); + + // Try to remove value from repository + await repository.RemoveAsync(key, combinedCts.Token); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // User cancelled - stop immediately + LogConsumer.Info("Get operation cancelled by user for key {0}", key); + throw; + } + catch (OperationCanceledException) when (timeoutCts.Token.IsCancellationRequested) + { + // Timeout occurred + LogConsumer.Error("Get operation timed out for key {0}", key); + throw new TimeoutException($"Get operation timed out for key {key}"); + } + catch (Exception ex) + { + var repositoryName = repository.GetType().Name; + errors.Add((repositoryName, ex)); + } + } + } + catch (OperationCanceledException) + { + Console.WriteLine("Cache operation was cancelled"); + throw; } } @@ -519,17 +1266,74 @@ public static void Remove([Localizable(false)] string key) /// /// The key of the cached value to remove. /// The sub key of the cached value to remove. - public static void Remove([Localizable(false)] string key, [Localizable(false)] string subKey) + /// Cancel token. + /// Completed task. + public static async ValueTask Remove( + [Localizable(false)] string key, + [Localizable(false)] string subKey, + CancellationToken cancellationToken = default + ) { - LogConsumer.Trace( - "Removing key {0} and sub key {2} from {1} repositories", - key, - _repositories.Count, - subKey - ); - foreach (var repository in _repositories.Values) + try { - repository.Remove(key, subKey); + cancellationToken.ThrowIfCancellationRequested(); + + LogConsumer.Trace( + "Removing key {0} and sub key {2} from {1} repositories", + key, + _repositories.Count, + subKey + ); + + // Create timeout for the entire operation + using var timeoutCts = new CancellationTokenSource(DefaultTimeout); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCts.Token + ); + + var errors = new List<(string Repository, Exception Error)>(); + + foreach (var repository in _repositories.Values) + { + try + { + combinedCts.Token.ThrowIfCancellationRequested(); + + var repositoryName = repository.GetType().Name; + + LogConsumer.Debug( + "Attempting to delete key {0} from repository {1}", + key, + repositoryName + ); + + // Try to remove value from repository + await repository.RemoveAsync(key, subKey, combinedCts.Token); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // User cancelled - stop immediately + LogConsumer.Info("Get operation cancelled by user for key {0}", key); + throw; + } + catch (OperationCanceledException) when (timeoutCts.Token.IsCancellationRequested) + { + // Timeout occurred + LogConsumer.Error("Get operation timed out for key {0}", key); + throw new TimeoutException($"Get operation timed out for key {key}"); + } + catch (Exception ex) + { + var repositoryName = repository.GetType().Name; + errors.Add((repositoryName, ex)); + } + } + } + catch (OperationCanceledException) + { + Console.WriteLine("Cache operation was cancelled"); + throw; } } @@ -538,20 +1342,55 @@ public static void Remove([Localizable(false)] string key, [Localizable(false)] /// /// The type of the cache repository. /// The key of the cached value. + /// Cancel token. /// The repository of type {type.FullName} isn't available in the repositories providers list. - public static void RemoveFrom([Localizable(false)] string key) + /// The operation get cancelled. + /// Completed task. + public static async ValueTask RemoveFrom( + [Localizable(false)] string key, + CancellationToken cancellationToken = default + ) { - var type = typeof(TCacheRepository); - LogConsumer.Trace("Removing key {0} from {1} repository", key, _repositories.Count); - var repository = _repositories.SingleOrDefault(r => type == r.Value.GetType()).Value; - if (repository == null) + try { - throw new InvalidOperationException( - $"The repository of type {type.FullName} isn't available in the repositories providers list" + cancellationToken.ThrowIfCancellationRequested(); + + var type = typeof(TCacheRepository); + + LogConsumer.Trace("Removing key {0} from {1} repository", key, _repositories.Count); + var repository = _repositories.SingleOrDefault(r => type == r.Value.GetType()).Value; + if (repository == null) + { + throw new InvalidOperationException( + $"The repository of type {type.FullName} isn't available in the repositories providers list" + ); + } + + // Create timeout for the operation + using var timeoutCts = new CancellationTokenSource(DefaultTimeout); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCts.Token ); - } - repository.Remove(key); + await repository.RemoveAsync(key, combinedCts.Token); + } + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) + { + LogConsumer.Info( + "Get operation cancelled by user for key {0} from {1}", + key, + typeof(TCacheRepository).Name + ); + throw; + } + catch (OperationCanceledException) + { + var error = + $"Get operation timed out for key {key} from {typeof(TCacheRepository).Name}"; + LogConsumer.Error(error); + throw new TimeoutException(error); + } } /// @@ -560,27 +1399,60 @@ public static void RemoveFrom([Localizable(false)] string key) /// The type of the cache repository. /// The key of the cached value. /// The sub key of the cached value to remove. + /// Cancel token. /// The repository of type {type.FullName} isn't available in the repositories providers list. - public static void RemoveFrom( + /// The operation get cancelled. + /// Completed task. + public static async ValueTask RemoveFrom( [Localizable(false)] string key, - [Localizable(false)] string subKey + [Localizable(false)] string subKey, + CancellationToken cancellationToken = default ) { - var type = typeof(TCacheRepository); - LogConsumer.Trace( - "Removing key {0} and sub key {2} from {1} repository", - key, - _repositories.Count, - subKey - ); - var repository = _repositories.SingleOrDefault(r => type == r.Value.GetType()).Value; - if (repository == null) + try { - throw new InvalidOperationException( - $"The repository of type {type.FullName} isn't available in the repositories providers list" + cancellationToken.ThrowIfCancellationRequested(); + + var type = typeof(TCacheRepository); + + LogConsumer.Trace( + "Removing key {0} and sub key {2} from {1} repository", + key, + _repositories.Count, + subKey ); - } + var repository = _repositories.SingleOrDefault(r => type == r.Value.GetType()).Value; + if (repository == null) + { + throw new InvalidOperationException( + $"The repository of type {type.FullName} isn't available in the repositories providers list" + ); + } - repository.Remove(key, subKey); + // Create timeout for the operation + using var timeoutCts = new CancellationTokenSource(DefaultTimeout); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCts.Token + ); + + await repository.RemoveAsync(key, subKey, combinedCts.Token); + } + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) + { + LogConsumer.Info( + "Get operation cancelled by user for key {0} from {1}", + key, + typeof(TCacheRepository).Name + ); + throw; + } + catch (OperationCanceledException) + { + var error = + $"Get operation timed out for key {key} from {typeof(TCacheRepository).Name}"; + LogConsumer.Error(error); + throw new TimeoutException(error); + } } } diff --git a/Src/CrispyWaffle/Cache/ICacheRepository.cs b/Src/CrispyWaffle/Cache/ICacheRepository.cs index 0a93e2d5..abf301b9 100644 --- a/Src/CrispyWaffle/Cache/ICacheRepository.cs +++ b/Src/CrispyWaffle/Cache/ICacheRepository.cs @@ -1,4 +1,6 @@ using System; +using System.Threading; +using System.Threading.Tasks; namespace CrispyWaffle.Cache; @@ -22,12 +24,19 @@ public interface ICacheRepository /// The value. /// The key. /// (Optional)The time to live for this key. + /// Cancel operation. + /// returns TaskValue. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Naming", "CA1716:Identifiers should not match keywords", Justification = "Design choice." )] - void Set(T value, string key, TimeSpan? ttl = null); + ValueTask SetAsync( + T value, + string key, + TimeSpan? ttl = null, + CancellationToken cancellationToken = default + ); /// /// Sets the specified value. @@ -36,18 +45,26 @@ public interface ICacheRepository /// The value. /// The key. /// The sub key. + /// To cancel the operation. + /// returns TaskValue. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Naming", "CA1716:Identifiers should not match keywords", Justification = "Design choice." )] - void Set(T value, string key, string subKey); + ValueTask SetAsync( + T value, + string key, + string subKey, + CancellationToken cancellationToken = default + ); /// /// Gets the object with the specified key. /// /// The type of object to return. /// The key. + /// Token to cancel request. /// The object as . /// Throws when the object with the specified key doesn't exists. [System.Diagnostics.CodeAnalysis.SuppressMessage( @@ -55,7 +72,7 @@ public interface ICacheRepository "CA1716:Identifiers should not match keywords", Justification = "Design choice." )] - T Get(string key); + ValueTask GetAsync(string key, CancellationToken cancellationToken = default); /// /// Gets the specified key. @@ -63,13 +80,18 @@ public interface ICacheRepository /// The type of object to return. /// The key. /// The sub key. + /// Token to cancel request. /// The object as . [System.Diagnostics.CodeAnalysis.SuppressMessage( "Naming", "CA1716:Identifiers should not match keywords", Justification = "Design choice." )] - T Get(string key, string subKey); + ValueTask GetAsync( + string key, + string subKey, + CancellationToken cancellationToken = default + ); /// /// Tries to get a value based on its key, if exists return true, else false. @@ -77,9 +99,12 @@ public interface ICacheRepository /// /// The type of object to return if found. /// The key. - /// The value. + /// Cancel token. /// Returns True if the object with the key exists, false otherwise. - bool TryGet(string key, out T value); + ValueTask<(bool Success, T Value)> TryGetAsync( + string key, + CancellationToken cancellationToken = default + ); /// /// Tries the get. @@ -87,32 +112,42 @@ public interface ICacheRepository /// The type of object to return if found. /// The key. /// The sub key. - /// The value. + /// Cancel token. /// Success info of the get, as a bool. - bool TryGet(string key, string subKey, out T value); + ValueTask<(bool Success, T Value)> TryGetAsync( + string key, + string subKey, + CancellationToken cancellationToken = default + ); /// /// Removes the specified key from the cache. /// /// The key. - void Remove(string key); + /// Cancel token. + /// Completed Task. + Task RemoveAsync(string key, CancellationToken cancellationToken = default); /// /// Removes the specified key. /// /// The key. /// The sub key. - void Remove(string key, string subKey); + /// Cancel token. + /// Completed Task. + Task RemoveAsync(string key, string subKey, CancellationToken cancellationToken = default); /// /// Returns the time to live of the specified key. /// /// The key. + /// Token to cancel the operation. /// The timespan until this key is expired from the cache or 0 if it's already expired or doesn't exists. - TimeSpan TTL(string key); + Task TTLAsync(string key, CancellationToken cancellationToken = default); /// /// Clears this instance. /// - void Clear(); + /// Completed Task. + Task Clear(); } diff --git a/Src/CrispyWaffle/Cache/MemoryCacheRepository.cs b/Src/CrispyWaffle/Cache/MemoryCacheRepository.cs index b23982e4..42a8d569 100644 --- a/Src/CrispyWaffle/Cache/MemoryCacheRepository.cs +++ b/Src/CrispyWaffle/Cache/MemoryCacheRepository.cs @@ -1,5 +1,8 @@ -using System; +using System; using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; namespace CrispyWaffle.Cache; @@ -35,9 +38,29 @@ public class MemoryCacheRepository : ICacheRepository /// The value. /// The key. /// This would be the TTL parameter, but it's not implemented in this type of cache (memory). Maybe in further version... + /// Cancellation token. /// The dictionary already contains the maximum number of elements. - public void Set(T value, string key, TimeSpan? ttl = null) => + /// If cancelled. + /// The Value. + public ValueTask SetAsync( + T value, + string key, + TimeSpan? ttl = null, + CancellationToken cancellationToken = default + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (_data.Count >= int.MaxValue) + { + throw new OverflowException( + "The dictionary already contains the maximum number of elements." + ); + } + _data.AddOrUpdate(key, value, (_, _) => value); + return default(ValueTask); + } /// /// Sets the specified value. @@ -46,11 +69,22 @@ public void Set(T value, string key, TimeSpan? ttl = null) => /// The value. /// The key. /// The sub key. + /// Cancel. /// The dictionary already contains the maximum number of elements. - public void Set(T value, string key, string subKey) + /// If cancelled. + /// ValuTask. + public ValueTask SetAsync( + T value, + string key, + string subKey, + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + var finalKey = $"{key}-{subKey}"; _hash.AddOrUpdate(finalKey, value, (_, _) => value); + return default(ValueTask); } /// @@ -58,10 +92,14 @@ public void Set(T value, string key, string subKey) /// /// The type of object (the object will be cast to this type). /// The key. + /// to cancel. /// The object as The type parameter. /// Throws when the object with the specified key doesn't exist. - public T Get(string key) + /// If cancelled. + public async ValueTask GetAsync(string key, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + if (!_data.TryGetValue(key, out var value)) { throw new InvalidOperationException($"Unable to get the item with key {key}"); @@ -76,72 +114,128 @@ public T Get(string key) /// The type parameter. /// The key. /// The sub key. - /// T. + /// to cancel. /// Unable to get the item with key {key} and sub key {subKey}. - public T Get(string key, string subKey) + /// If cancelled. + /// Invalid cast. + /// T. + public ValueTask GetAsync( + string key, + string subKey, + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); + var finalKey = $"{key}-{subKey}"; - if (!_hash.TryGetValue(finalKey, out var value)) + if (!_hash.TryGetValue(finalKey, out var temp)) { throw new InvalidOperationException( $"Unable to get the item with key {key} and sub key {subKey}" ); } - return (T)value; + try + { + var value = (T)temp; + return new ValueTask(value); + } + catch (InvalidCastException) + { + throw new InvalidCastException( + $"Unable to convert the item with key {key} and sub key {subKey}" + ); + } } /// - /// Tries to get a value based on its key, if exists return true, else false. - /// The out parameter value is the object requested. + /// Attempts to retrieve a value from the cache by key. /// /// The type of object (the object will be cast to this type). /// The key. - /// The value. - /// Returns True if the object with the key exists, false otherwise. - public bool TryGet(string key, out T value) + /// Token to cancel the operation. + /// If cancelled. + /// Invalid cast of value. + /// + /// A tuple containing Success (true if found and castable to T) and Value (the cached item or default(T)). + /// + public ValueTask<(bool Success, T Value)> TryGetAsync( + string key, + CancellationToken cancellationToken = default + ) { - value = default; + cancellationToken.ThrowIfCancellationRequested(); + if (!_data.TryGetValue(key, out var temp)) { - return false; + return new ValueTask<(bool Success, T Value)>((Success: false, Value: default(T))); } - value = (T)temp; - return true; + try + { + var value = (T)temp; + return new ValueTask<(bool Success, T Value)>((Success: true, Value: value)); + } + catch (InvalidCastException) + { + return new ValueTask<(bool Success, T Value)>((Success: false, Value: default(T))); + } } /// - /// Tries the get. + /// Attempts to retrieve a value from the cache by key. /// /// The type parameter. /// The key. /// The sub key. - /// The value. - /// true if able to get the key and sub key, false otherwise. - public bool TryGet(string key, string subKey, out T value) + /// Cancel token. + /// If cancelled. + /// Invalid cast of value. + /// + /// A tuple containing Success (true if found and castable to T) and Value (the cached item or default(T)). + /// + public ValueTask<(bool Success, T Value)> TryGetAsync( + string key, + string subKey, + CancellationToken cancellationToken = default + ) { - value = default; + cancellationToken.ThrowIfCancellationRequested(); + var finalKey = $"{key}-{subKey}"; if (!_hash.TryGetValue(finalKey, out var temp)) { - return false; + return new ValueTask<(bool Success, T Value)>((Success: false, Value: default(T))); } - value = (T)temp; - return true; + try + { + var value = (T)temp; + return new ValueTask<(bool Success, T Value)>((Success: true, Value: value)); + } + catch (InvalidCastException) + { + return new ValueTask<(bool Success, T Value)>((Success: false, Value: default(T))); + } } /// /// Removes the specified key from the cache. /// /// The key. - public void Remove(string key) + /// Cancel token. + /// If cancelled. + /// Completed Task. + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + if (_data.ContainsKey(key)) { _data.TryRemove(key, out _); } + + return Task.CompletedTask; } /// @@ -149,31 +243,46 @@ public void Remove(string key) /// /// The key. /// The sub key. - public void Remove(string key, string subKey) + /// Cancel token. + /// Completed Task. + public Task RemoveAsync( + string key, + string subKey, + CancellationToken cancellationToken = default + ) { + cancellationToken.ThrowIfCancellationRequested(); var finalKey = $"{key}-{subKey}"; if (_data.ContainsKey(finalKey)) { _hash.TryRemove(finalKey, out _); } + + return Task.CompletedTask; } /// /// Returns the time to live of the specified key. /// /// The key. + /// Token to cancel the operation /// /// The timespan until this key is expired from the cache or 0 if it's already expired or doesn't exist. /// As Memory Cache does not implement TTL or expire mechanism, this will always return 0, even if the key exists. /// - public TimeSpan TTL(string key) => new TimeSpan(0); + public Task TTLAsync(string key, CancellationToken cancellationToken = default) + { + return Task.FromResult(TimeSpan.Zero); + } /// /// Clears this instance. /// - public void Clear() + /// Completed Task. + public Task Clear() { _data.Clear(); _hash.Clear(); + return Task.CompletedTask; } } diff --git a/Tests/CrispyWaffle.IntegrationTests/Cache/CouchDBCacheRepositoryTests.cs b/Tests/CrispyWaffle.IntegrationTests/Cache/CouchDBCacheRepositoryTests.cs index aa1c1b4a..eb75bebd 100644 --- a/Tests/CrispyWaffle.IntegrationTests/Cache/CouchDBCacheRepositoryTests.cs +++ b/Tests/CrispyWaffle.IntegrationTests/Cache/CouchDBCacheRepositoryTests.cs @@ -24,83 +24,97 @@ public CouchDBCacheRepositoryTests() } [Fact] - public void GetAndSetCouchDocTest() + public async Task GetAndSetAsyncCouchDocTest() { var doc = new CouchDBCacheDocument(); - _repository.Set(doc, Guid.NewGuid().ToString()); + await _repository.SetAsync(doc, Guid.NewGuid().ToString()); - var docDB = _repository.Get(doc.Key); + var docDB = await _repository.GetAsync(doc.Key); Assert.True(doc.Key == docDB.Key); - _repository.Remove(doc.Key); + await _repository.RemoveAsync(doc.Key); } [Fact] - public void GetAndSetSpecificTest() + public async Task GetAndSetSpecificAsyncTest() { var docOne = new Car("MakerOne"); - _repository.SetSpecific(docOne, Guid.NewGuid().ToString(), Guid.NewGuid().ToString()); + await _repository.SetSpecificAsync( + docOne, + Guid.NewGuid().ToString(), + Guid.NewGuid().ToString() + ); var docTwo = new Car("MakerTwo"); - _repository.SetSpecific(docTwo, Guid.NewGuid().ToString()); + await _repository.SetSpecificAsync(docTwo, Guid.NewGuid().ToString()); - var docDB = _repository.GetSpecific(docOne.Key); + var docDB = await _repository.GetSpecificAsync(docOne.Key); Assert.True( docOne.Key == docDB.Key && docOne.SubKey == docDB.SubKey && docOne.Maker == "MakerOne" ); - docDB = _repository.GetSpecific(docTwo.Key); + docDB = await _repository.GetSpecificAsync(docTwo.Key); Assert.True(docTwo.Key == docDB.Key && docTwo.Maker == "MakerTwo"); - _repository.RemoveSpecific(docOne.Key); - _repository.RemoveSpecific(docTwo.Key); + await _repository.RemoveSpecificAsync(docOne.Key); + await _repository.RemoveSpecificAsync(docTwo.Key); } [Fact] - public void RemoveCouchDocTest() + public async Task RemoveAsyncCouchDocTest() { var doc = new CouchDBCacheDocument(); - _repository.Set(doc, Guid.NewGuid().ToString()); + await _repository.SetAsync(doc, Guid.NewGuid().ToString()); - _repository.Remove(doc.Key); + await _repository.RemoveAsync(doc.Key); - var docDB = _repository.Get(doc.Key); + var docDB = await _repository.GetAsync(doc.Key); Assert.True(docDB == default); } [Fact] - public void RemoveSpecificTest() + public async Task RemoveSpecificAsyncTest() { var doc = new Car("Maker"); - _repository.SetSpecific(doc, Guid.NewGuid().ToString()); + await _repository.SetSpecificAsync(doc, Guid.NewGuid().ToString()); - _repository.RemoveSpecific(doc.Key); + await _repository.RemoveSpecificAsync(doc.Key); - var docDB = _repository.Get(doc.Key); + var docDB = await _repository.GetAsync(doc.Key); Assert.True(docDB == default); } [Fact] - public void DatabaseClearTest() + public async Task DatabaseClearTest() { - _repository.Set(new CouchDBCacheDocument(), Guid.NewGuid().ToString()); - _repository.Set(new CouchDBCacheDocument(), Guid.NewGuid().ToString()); - _repository.Set(new CouchDBCacheDocument(), Guid.NewGuid().ToString()); - _repository.Set(new CouchDBCacheDocument(), Guid.NewGuid().ToString()); + var task1 = _repository + .SetAsync(new CouchDBCacheDocument(), Guid.NewGuid().ToString()) + .AsTask(); + var task2 = _repository + .SetAsync(new CouchDBCacheDocument(), Guid.NewGuid().ToString()) + .AsTask(); + var task3 = _repository + .SetAsync(new CouchDBCacheDocument(), Guid.NewGuid().ToString()) + .AsTask(); + var task4 = _repository + .SetAsync(new CouchDBCacheDocument(), Guid.NewGuid().ToString()) + .AsTask(); + + await Task.WhenAll(task1, task2, task3, task4); - _repository.Clear(); + await _repository.Clear(); - var count = _repository.GetDocCount(); + var count = await _repository.GetDocCount(); Assert.True(count == 0); } @@ -110,14 +124,14 @@ public async Task TTLGetTest() { var doc = new CouchDBCacheDocument() { Key = Guid.NewGuid().ToString() }; - _repository.Set(new CouchDBCacheDocument(), doc.Key, new TimeSpan(0, 0, 5)); - var fromDB = _repository.Get(doc.Key); + await _repository.SetAsync(new CouchDBCacheDocument(), doc.Key, new TimeSpan(0, 0, 5)); + var fromDB = await _repository.GetAsync(doc.Key); Assert.True(doc.Key == fromDB.Key); await Task.Delay(6000); - fromDB = _repository.Get(doc.Key); + fromDB = await _repository.GetAsync(doc.Key); Assert.True(fromDB == null); } diff --git a/Tests/CrispyWaffle.Tests/Cache/CacheManagerTests.cs b/Tests/CrispyWaffle.Tests/Cache/CacheManagerTests.cs index 63f0ac25..83e8e186 100644 --- a/Tests/CrispyWaffle.Tests/Cache/CacheManagerTests.cs +++ b/Tests/CrispyWaffle.Tests/Cache/CacheManagerTests.cs @@ -1,4 +1,6 @@ using System; +using System.Threading; +using System.Threading.Tasks; using CrispyWaffle.Cache; using FluentAssertions; using NSubstitute; @@ -45,7 +47,7 @@ public void AddRepositoryShouldAddRepositoryWithSpecifiedPriority() } [Fact] - public void SetShouldSetValueInAllRepositories() + public async Task SetAsyncShouldSetValueInAllRepositories() { // Arrange var key = "testKey"; @@ -53,14 +55,14 @@ public void SetShouldSetValueInAllRepositories() CacheManager.AddRepository(_mockCacheRepository); // Act - CacheManager.Set(value, key); + await CacheManager.SetAsync(value, key); // Assert - _mockCacheRepository.Received().Set(value, key); + await _mockCacheRepository.Received(1).SetAsync(value, key); } [Fact] - public void SetShouldSetValueInRepositoriesWithTTL() + public async Task SetAsyncShouldSetValueInRepositoriesWithTTL() { // Arrange var key = "testKey"; @@ -69,72 +71,82 @@ public void SetShouldSetValueInRepositoriesWithTTL() CacheManager.AddRepository(_mockCacheRepository); // Act - CacheManager.Set(value, key, ttl); + await CacheManager.SetAsync(value, key, ttl); // Assert - _mockCacheRepository.Received().Set(value, key, ttl); + await _mockCacheRepository.Received(1).SetAsync(value, key, ttl); } [Fact] - public void GetShouldThrowWhenItemNotFound() + public async Task SetAsyncShouldSetValueWithSubKey() { // Arrange var key = "testKey"; - _mockCacheRepository.TryGet(key, out Arg.Any()).Returns(false); + var subKey = "testSubKey"; + var value = new { Name = "Test" }; + CacheManager.AddRepository(_mockCacheRepository); // Act - Action act = () => CacheManager.Get(key); + await CacheManager.SetAsync(value, key, subKey); // Assert - act.Should() - .Throw() - .WithMessage("Unable to get the item with key testKey"); + await _mockCacheRepository + .Received(1) + .SetAsync(value, key, subKey, Arg.Any()); } [Fact] - public void SetToShouldThrowWhenRepositoryNotFound() + public async Task GetAsyncShouldThrowWhenItemNotFound() { // Arrange var key = "testKey"; - var value = new { Name = "Test" }; CacheManager.AddRepository(_mockCacheRepository); - // Act - Action act = () => CacheManager.SetTo(value, key); + Func act = async () => await CacheManager.GetAsync(key); // Assert - act.Should() - .Throw() - .WithMessage( - "The repository of type CrispyWaffle.Cache.ICacheRepository isn't available in the repositories providers list" - ); + await act.Should() + .ThrowAsync() + .WithMessage("Unable to get the item with key testKey"); } [Fact] - public void RemoveShouldRemoveKeyFromAllRepositories() + public async Task SetToShouldThrowWhenRepositoryNotFound() { // Arrange var key = "testKey"; + var value = new { Name = "Test" }; CacheManager.AddRepository(_mockCacheRepository); // Act - CacheManager.Remove(key); + Func act = async () => + await CacheManager.SetToAsync(value, key); // Assert - _mockCacheRepository.Received().Remove(key); + await act.Should() + .ThrowAsync() + .WithMessage( + "The repository of type CrispyWaffle.Cache.ICacheRepository isn't available in the repositories providers list" + ); } [Fact] - public void TTLShouldReturnCorrectTTLFromRepositories() + public async Task TTLShouldReturnCorrectTTLFromRepositories() { // Arrange var key = "testKey"; var expectedTTL = TimeSpan.FromMinutes(10); CacheManager.AddRepository(_mockCacheRepository); - _mockCacheRepository.TTL(key).Returns(expectedTTL); + _mockCacheRepository.TTLAsync(key, Arg.Any()).Returns(expectedTTL); + await CacheManager.SetAsync( + new { Name = "Test" }, + key, + expectedTTL, + CancellationToken.None + ); // Set value with TTL // Act - var result = CacheManager.TTL(key); + var result = await CacheManager.TTLAsync(key); // Assert result.Should().Be(expectedTTL); @@ -148,11 +160,11 @@ public void RemoveFromShouldThrowWhenRepositoryNotFound() CacheManager.AddRepository(_mockCacheRepository); // Act - Action act = () => CacheManager.RemoveFrom(key); + Func act = async () => await CacheManager.RemoveFrom(key); // Assert act.Should() - .Throw() + .ThrowAsync() .WithMessage( "The repository of type CrispyWaffle.Cache.ICacheRepository isn't available in the repositories providers list" ); diff --git a/Tests/CrispyWaffle.Tests/Cache/CouchDBCacheRepositoryTests.cs b/Tests/CrispyWaffle.Tests/Cache/CouchDBCacheRepositoryTests.cs index 0d6b4b4f..a05016a7 100644 --- a/Tests/CrispyWaffle.Tests/Cache/CouchDBCacheRepositoryTests.cs +++ b/Tests/CrispyWaffle.Tests/Cache/CouchDBCacheRepositoryTests.cs @@ -1,3 +1,4 @@ +using System.Threading.Tasks; using CouchDB.Driver; using CrispyWaffle.CouchDB.Cache; using CrispyWaffle.CouchDB.Utils.Communications; @@ -24,40 +25,46 @@ public CouchDBCacheRepositoryTests() } [Fact] - public void SetToDatabaseShouldStoreValue() + public async Task SetToDatabaseShouldStoreValue() { // Arrange var key = "test-key"; var value = "test-value"; // Act - _repository.Set(value, key); + await _repository.SetAsync(value, key); // Assert } [Fact] - public void GetFromDatabaseShouldReturnStoredValue() + public async Task GetFromDatabaseShouldReturnStoredValue() { // Arrange var key = "test-key"; // Act - var actualValue = _repository.Get(key); + var actualValue = await _repository.GetAsync(key); // Assert actualValue.Should().BeNull(); } [Fact] - public void RemoveFromDatabaseShouldRemoveValue() + public async Task RemoveFromDatabaseShouldRemoveValue() { // Arrange var key = "test-key"; + var value = "test-value"; + + // Act + await _repository.SetAsync(value, key); // Act - _repository.Remove(key); + await _repository.RemoveAsync(key); + (bool success, _) = await _repository.TryGetAsync(key); // Assert + success.Should().Be(false); } } diff --git a/Tests/CrispyWaffle.Tests/Cache/MemoryCacheRepositoryTests.cs b/Tests/CrispyWaffle.Tests/Cache/MemoryCacheRepositoryTests.cs index 38bb77ca..e072bea2 100644 --- a/Tests/CrispyWaffle.Tests/Cache/MemoryCacheRepositoryTests.cs +++ b/Tests/CrispyWaffle.Tests/Cache/MemoryCacheRepositoryTests.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using CrispyWaffle.Cache; using FluentAssertions; using Xunit; @@ -12,47 +13,48 @@ public class MemoryCacheRepositoryTests public MemoryCacheRepositoryTests() => _repository = new MemoryCacheRepository(); [Fact] - public void SetShouldStoreValue() + public async Task SetAsyncShouldStoreValue() { // Arrange var key = "test-key"; var value = "test-value"; - _repository.Set(value, key); + await _repository.SetAsync(value, key); // Act - var actualValue = _repository.Get(key); + var actualValue = await _repository.GetAsync(key); // Assert actualValue.Should().Be(value); } [Fact] - public void GetShouldReturnStoredValue() + public async Task GetShouldReturnStoredValue() { // Arrange var key = "test-key"; var expectedValue = "test-value"; - _repository.Set(expectedValue, key); + await _repository.SetAsync(expectedValue, key); // Act - var actualValue = _repository.Get(key); + var actualValue = await _repository.GetAsync(key); // Assert actualValue.Should().Be(expectedValue); } [Fact] - public void RemoveShouldRemoveStoredValue() + public async Task RemoveShouldRemoveStoredValue() { // Arrange var key = "test-key"; - _repository.Set("test-value", key); - _repository.Remove(key); + var Task1 = _repository.SetAsync("test-value", key).AsTask(); + var Task2 = _repository.RemoveAsync(key); + await Task.WhenAll(Task1, Task2); // Act - var exception = Assert.Throws(() => - _repository.Get(key) + var exception = Assert.ThrowsAsync(async () => + await _repository.GetAsync(key) ); // Assert diff --git a/Tests/CrispyWaffle.Tests/Scheduler/JobRunnerTests.cs b/Tests/CrispyWaffle.Tests/Scheduler/JobRunnerTests.cs index c84d979d..ab1477d3 100644 --- a/Tests/CrispyWaffle.Tests/Scheduler/JobRunnerTests.cs +++ b/Tests/CrispyWaffle.Tests/Scheduler/JobRunnerTests.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using System.Threading.Tasks; using CrispyWaffle.Scheduler; using Xunit; @@ -60,10 +61,10 @@ public async Task ValidateConcurrency() var runner = new JobRunner( "*", - async () => + () => { sampler.Counter++; - await Task.Delay(sleepMilliseconds); + Thread.Sleep(sleepMilliseconds); } ); diff --git a/Tests/CrispyWaffle.Tests/TestLogProvider.cs b/Tests/CrispyWaffle.Tests/TestLogProvider.cs index ce8ade52..9712da41 100644 --- a/Tests/CrispyWaffle.Tests/TestLogProvider.cs +++ b/Tests/CrispyWaffle.Tests/TestLogProvider.cs @@ -18,16 +18,16 @@ public TestLogProvider(ITestOutputHelper testOutputHelper) => testOutputHelper ?? throw new ArgumentNullException(nameof(testOutputHelper)); public void SetLevel(LogLevel level) => - _testOutputHelper.WriteLine("Set level: {0}", level.GetHumanReadableValue()); + SafeWriteLine("Set level: {0}", level.GetHumanReadableValue()); public void Fatal(string category, string message) => - _testOutputHelper.WriteLine("Fatal: {0} - {1}", category, message); + SafeWriteLine("Fatal: {0} - {1}", category, message); public void Error(string category, string message) => - _testOutputHelper.WriteLine("Error: {0} - {1}", category, message); + SafeWriteLine("Error: {0} - {1}", category, message); public void Warning(string category, string message) => - _testOutputHelper.WriteLine("Warning: {0} - {1}", category, message); + SafeWriteLine("Warning: {0} - {1}", category, message); public void Info(string category, string message) => System.Diagnostics.Trace.WriteLine($"Info: {category} - {message}"); @@ -37,7 +37,7 @@ public void Trace(string category, string message) => public void Trace(string category, string message, Exception exception) { - _testOutputHelper.WriteLine("Trace: {0} - {1}", category, message); + SafeWriteLine("Trace: {0} - {1}", category, message); WriteExceptionDetails("Trace", category, exception); } @@ -45,12 +45,12 @@ public void Trace(string category, Exception exception) => WriteExceptionDetails("Trace", category, exception); public void Debug(string category, string message) => - _testOutputHelper.WriteLine("Debug: {0} - {1}", category, message); + SafeWriteLine("Debug: {0} - {1}", category, message); public void Debug(string category, string content, string identifier) { - _testOutputHelper.WriteLine("Error: {0} - {1}", category, identifier); - _testOutputHelper.WriteLine(content); + SafeWriteLine("Error: {0} - {1}", category, identifier); + SafeWriteLine(content); } public void Debug( @@ -61,22 +61,49 @@ SerializerFormat customFormat ) where T : class, new() { - _testOutputHelper.WriteLine("Error: {0} - {1}", category, identifier); - _testOutputHelper.WriteLine((string)content.GetCustomSerializer(customFormat)); + SafeWriteLine("Error: {0} - {1}", category, identifier); + SafeWriteLine((string)content.GetCustomSerializer(customFormat)); + } + + /// + /// Writes to the test output, swallowing failures caused by the owning test having + /// already completed (e.g. this provider is stale, from a prior parallel test run). + /// + private void SafeWriteLine(string message) + { + try + { + _testOutputHelper.WriteLine(message); + } + catch (InvalidOperationException) + { + // The test that registered this provider has already finished; ignore. + } + } + + /// + /// Writes to the test output, swallowing failures caused by the owning test having + /// already completed (e.g. this provider is stale, from a prior parallel test run). + /// + private void SafeWriteLine(string format, params object[] args) + { + try + { + _testOutputHelper.WriteLine(format, args); + } + catch (InvalidOperationException) + { + // The test that registered this provider has already finished; ignore. + } } private void WriteExceptionDetails(string level, string category, Exception exception) { do { - _testOutputHelper.WriteLine("{0}: {1} - {2}", level, category, exception.Message); - _testOutputHelper.WriteLine( - "{0}: {1} - {2}", - level, - category, - exception.GetType().FullName - ); - _testOutputHelper.WriteLine("{0}: {1} - {2}", level, category, exception.StackTrace); + SafeWriteLine("{0}: {1} - {2}", level, category, exception.Message); + SafeWriteLine("{0}: {1} - {2}", level, category, exception.GetType().FullName); + SafeWriteLine("{0}: {1} - {2}", level, category, exception.StackTrace); exception = exception.InnerException; } while (exception != null);