From 63287f5d7b2d3574cd9097c83869f3ea5b5e899f Mon Sep 17 00:00:00 2001 From: adrianwium <82496337+adrianwium@users.noreply.github.com> Date: Fri, 27 Sep 2024 08:26:19 +0200 Subject: [PATCH] =?UTF-8?q?Moved=20from=20IMemoryCache=20to=20IDistributed?= =?UTF-8?q?Cache,=20catering=20for=20multiple=20i=E2=80=A6=20(#1115)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Moved from IMemoryCache to IDistributedCache, catering for multiple intances of the api * Change store and category info cache to expire on an hourly basis ensuring new stores are picked-up --- .../Interfaces/IDistributedCacheService.cs | 15 +++ .../Core/Services/DistributedCacheService.cs | 94 +++++++++++++++++++ .../StoreAccessControlRuleInfoService.cs | 48 +++++----- .../Services/StoreAccessControlRuleService.cs | 31 +++--- .../src/domain/Yoma.Core.Domain/Startup.cs | 1 + 5 files changed, 149 insertions(+), 40 deletions(-) create mode 100644 src/api/src/domain/Yoma.Core.Domain/Core/Interfaces/IDistributedCacheService.cs create mode 100644 src/api/src/domain/Yoma.Core.Domain/Core/Services/DistributedCacheService.cs diff --git a/src/api/src/domain/Yoma.Core.Domain/Core/Interfaces/IDistributedCacheService.cs b/src/api/src/domain/Yoma.Core.Domain/Core/Interfaces/IDistributedCacheService.cs new file mode 100644 index 000000000..6837f3862 --- /dev/null +++ b/src/api/src/domain/Yoma.Core.Domain/Core/Interfaces/IDistributedCacheService.cs @@ -0,0 +1,15 @@ +namespace Yoma.Core.Domain.Core.Interfaces +{ + public interface IDistributedCacheService + { + T GetOrCreate(string key, Func valueProvider, TimeSpan? slidingExpiration = null, TimeSpan? absoluteExpirationRelativeToNow = null) + where T : class; + + Task GetOrCreateAsync(string key, Func> valueProvider, TimeSpan? slidingExpiration = null, TimeSpan? absoluteExpirationRelativeToNow = null) + where T : class; + + void Remove(string key); + + Task RemoveAsync(string key); + } +} diff --git a/src/api/src/domain/Yoma.Core.Domain/Core/Services/DistributedCacheService.cs b/src/api/src/domain/Yoma.Core.Domain/Core/Services/DistributedCacheService.cs new file mode 100644 index 000000000..8e2fa7649 --- /dev/null +++ b/src/api/src/domain/Yoma.Core.Domain/Core/Services/DistributedCacheService.cs @@ -0,0 +1,94 @@ +using Newtonsoft.Json; +using StackExchange.Redis; +using Yoma.Core.Domain.Core.Interfaces; + +namespace Yoma.Core.Domain.Core.Services +{ + public class DistributedCacheService : IDistributedCacheService + { + #region Class Variables + private readonly IDatabase _database; + #endregion + + #region Constructor + public DistributedCacheService(IConnectionMultiplexer connectionMultiplexer) + { + _database = connectionMultiplexer.GetDatabase(); + } + #endregion + + #region Public Members + public T GetOrCreate(string key, Func valueProvider, TimeSpan? slidingExpiration = null, TimeSpan? absoluteExpirationRelativeToNow = null) + where T : class + { + return GetOrCreateInternalAsync(key, () => Task.FromResult(valueProvider()), slidingExpiration, absoluteExpirationRelativeToNow).Result; + } + + public async Task GetOrCreateAsync(string key, Func> valueProvider, TimeSpan? slidingExpiration = null, TimeSpan? absoluteExpirationRelativeToNow = null) + where T : class + { + return await GetOrCreateInternalAsync(key, valueProvider, slidingExpiration, absoluteExpirationRelativeToNow); + } + + public void Remove(string key) + { + RemoveInternalAsync(key).Wait(); + } + + public async Task RemoveAsync(string key) + { + await RemoveInternalAsync(key); + } + #endregion + + #region Private Members + private async Task GetOrCreateInternalAsync(string key, Func> valueProvider, TimeSpan? slidingExpiration = null, TimeSpan? absoluteExpirationRelativeToNow = null) + where T : class + { + ArgumentException.ThrowIfNullOrWhiteSpace(key, nameof(key)); + key = key.Trim(); + + ArgumentNullException.ThrowIfNull(valueProvider, nameof(valueProvider)); + + if (slidingExpiration.HasValue && absoluteExpirationRelativeToNow.HasValue && slidingExpiration > absoluteExpirationRelativeToNow) + throw new InvalidOperationException("'Sliding Expiration' cannot be longer than 'Absolute Expiration Relative to Now'"); + + var redisValueWithExpiry = await _database.StringGetWithExpiryAsync(key); + + if (redisValueWithExpiry.Value.HasValue) + { + var cachedValue = JsonConvert.DeserializeObject(redisValueWithExpiry.Value!) + ?? throw new InvalidOperationException($"Failed to deserialize value for key '{key}'"); + + if (slidingExpiration.HasValue && redisValueWithExpiry.Expiry.HasValue) + { + var newExpiration = slidingExpiration.Value < redisValueWithExpiry.Expiry.Value + ? slidingExpiration.Value + : redisValueWithExpiry.Expiry.Value; + + await _database.KeyExpireAsync(key, newExpiration); + } + + return cachedValue; + } + + var value = await valueProvider(); + var serializedValue = JsonConvert.SerializeObject(value); + var expiration = absoluteExpirationRelativeToNow ?? slidingExpiration; + + await _database.StringSetAsync(key, serializedValue, expiration); + + return value; + } + + private async Task RemoveInternalAsync(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key, nameof(key)); + key = key.Trim(); + + await _database.KeyDeleteAsync(key); + } + } + #endregion +} + diff --git a/src/api/src/domain/Yoma.Core.Domain/Marketplace/Services/StoreAccessControlRuleInfoService.cs b/src/api/src/domain/Yoma.Core.Domain/Marketplace/Services/StoreAccessControlRuleInfoService.cs index 6182fef6c..32fd65829 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Marketplace/Services/StoreAccessControlRuleInfoService.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Marketplace/Services/StoreAccessControlRuleInfoService.cs @@ -1,7 +1,7 @@ using FluentValidation; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using Yoma.Core.Domain.Core.Helpers; +using Yoma.Core.Domain.Core.Interfaces; using Yoma.Core.Domain.Core.Models; using Yoma.Core.Domain.Marketplace.Interfaces; using Yoma.Core.Domain.Marketplace.Models; @@ -13,7 +13,7 @@ public class StoreAccessControlRuleInfoService : IStoreAccessControlRuleInfoServ { #region Class Variables private readonly AppSettings _appSettings; - private readonly IMemoryCache _memoryCache; + private readonly IDistributedCacheService _distributedCacheService; private readonly IStoreAccessControlRuleService _storeAccessControlRuleService; private readonly IMarketplaceService _marketplaceService; private readonly StoreAccessControlRuleRequestValidatorCreate _storeAccessControlRuleRequestValidatorCreate; @@ -22,14 +22,14 @@ public class StoreAccessControlRuleInfoService : IStoreAccessControlRuleInfoServ #region Constructor public StoreAccessControlRuleInfoService(IOptions appSettings, - IMemoryCache memoryCache, + IDistributedCacheService distributedCacheService, IStoreAccessControlRuleService storeAccessControlRuleService, IMarketplaceService marketplaceService, StoreAccessControlRuleRequestValidatorCreate storeAccessControlRuleRequestValidatorCreate, StoreAccessControlRuleRequestValidatorUpdate storeAccessControlRuleRequestValidatorUpdate) { _appSettings = appSettings.Value; - _memoryCache = memoryCache; + _distributedCacheService = distributedCacheService; _storeAccessControlRuleService = storeAccessControlRuleService; _marketplaceService = marketplaceService; _storeAccessControlRuleRequestValidatorCreate = storeAccessControlRuleRequestValidatorCreate; @@ -107,9 +107,6 @@ public async Task Create(StoreAccessControlRuleReque request.RequestValidationHandled = true; var result = await _storeAccessControlRuleService.Create(request); - _memoryCache.Remove(CacheHelper.GenerateKey(result.StoreCountryCodeAlpha2)); - _memoryCache.Remove(CacheHelper.GenerateKey(result.StoreId)); - return await ToInfo(result); } @@ -147,9 +144,6 @@ public async Task Update(StoreAccessControlRuleReque request.RequestValidationHandled = true; var result = await _storeAccessControlRuleService.Update(request); - _memoryCache.Remove(CacheHelper.GenerateKey(result.StoreCountryCodeAlpha2)); - _memoryCache.Remove(CacheHelper.GenerateKey(result.StoreId)); - return await ToInfo(result); } @@ -239,39 +233,41 @@ private async Task ToInfo(StoreAccessControlRule ite } /// - /// Caches the stores for store info resolution. The cache expires based on the cache settings or when a rule is created or updated. The cache applies to existing rules. - /// When a rule is created or updated, all stores become selectable, so expiring the store info cache is necessary to reflect the latest changes. + /// Caches the stores for store info resolution. The cache expires every hour based on sliding expiration settings. /// private async Task> StoresCached(string countryCodeAlpha2) { if (!_appSettings.CacheEnabledByCacheItemTypesAsEnum.HasFlag(Core.CacheItemType.Lookups)) return (await _marketplaceService.SearchStores(new StoreSearchFilter { CountryCodeAlpha2 = countryCodeAlpha2 })).Items; - var result = await _memoryCache.GetOrCreateAsync(CacheHelper.GenerateKey(countryCodeAlpha2), async entry => - { - entry.SlidingExpiration = TimeSpan.FromHours(_appSettings.CacheSlidingExpirationInHours); - entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(_appSettings.CacheAbsoluteExpirationRelativeToNowInDays); - return (await _marketplaceService.SearchStores(new StoreSearchFilter { CountryCodeAlpha2 = countryCodeAlpha2 })).Items; - }) ?? throw new InvalidOperationException($"Failed to retrieve cached list of '{nameof(StoreSearchResults)}s'"); + var result = await _distributedCacheService.GetOrCreateAsync( + CacheHelper.GenerateKey(countryCodeAlpha2), + async () => (await _marketplaceService.SearchStores(new StoreSearchFilter { CountryCodeAlpha2 = countryCodeAlpha2 })).Items, + null, // No absolute expiration + TimeSpan.FromHours(_appSettings.CacheSlidingExpirationInHours) // Sliding expiration as absolute ensures new stores/categories are picked up + ) ?? throw new InvalidOperationException($"Failed to retrieve cached list of '{nameof(StoreSearchResults)}s'"); return result; } /// - /// Caches the store item categories for store item category resolution. The cache expires based on the cache settings or when a rule is created or updated. The cache applies to existing rules. - /// When a rule is created or updated, all store item categories become selectable, so expiring the store item category cache is necessary to reflect the latest changes. + /// Caches the categories for store item category resolution. The cache expires every hour based on sliding expiration settings /// private async Task> StoreItemCategoriesCached(string storeId) { if (!_appSettings.CacheEnabledByCacheItemTypesAsEnum.HasFlag(Core.CacheItemType.Lookups)) return (await _marketplaceService.SearchStoreItemCategories(new StoreItemCategorySearchFilter { StoreId = storeId, EvaluateStoreAccessControlRules = false })).Items; - var result = await _memoryCache.GetOrCreateAsync(CacheHelper.GenerateKey(storeId), async entry => - { - entry.SlidingExpiration = TimeSpan.FromHours(_appSettings.CacheSlidingExpirationInHours); - entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(_appSettings.CacheAbsoluteExpirationRelativeToNowInDays); - return (await _marketplaceService.SearchStoreItemCategories(new StoreItemCategorySearchFilter { StoreId = storeId, EvaluateStoreAccessControlRules = false })).Items; - }) ?? throw new InvalidOperationException($"Failed to retrieve cached list of '{nameof(StoreItemCategorySearchResults)}s'"); + var result = await _distributedCacheService.GetOrCreateAsync( + CacheHelper.GenerateKey(storeId), + async () => (await _marketplaceService.SearchStoreItemCategories(new StoreItemCategorySearchFilter + { + StoreId = storeId, + EvaluateStoreAccessControlRules = false + })).Items, + null, // No absolute expiration + TimeSpan.FromHours(_appSettings.CacheSlidingExpirationInHours) // Sliding expiration as absolute ensures new stores/categories are picked up + ) ?? throw new InvalidOperationException($"Failed to retrieve cached list of '{nameof(StoreItemCategorySearchResults)}s'"); return result; } diff --git a/src/api/src/domain/Yoma.Core.Domain/Marketplace/Services/StoreAccessControlRuleService.cs b/src/api/src/domain/Yoma.Core.Domain/Marketplace/Services/StoreAccessControlRuleService.cs index 030502f6f..23bc703f4 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Marketplace/Services/StoreAccessControlRuleService.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Marketplace/Services/StoreAccessControlRuleService.cs @@ -1,6 +1,5 @@ using FluentValidation; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using Newtonsoft.Json; using System.Transactions; @@ -29,7 +28,7 @@ public class StoreAccessControlRuleService : IStoreAccessControlRuleService { #region Class Variables private readonly AppSettings _appSettings; - private readonly IMemoryCache _memoryCache; + private readonly IDistributedCacheService _distributedCacheService; private readonly IHttpContextAccessor _httpContextAccessor; private readonly IStoreAccessControlRuleStatusService _storeAccessControlRuleStatusService; private readonly IOrganizationService _organizationService; @@ -56,7 +55,7 @@ public class StoreAccessControlRuleService : IStoreAccessControlRuleService #region Constructor public StoreAccessControlRuleService(IOptions appSettings, - IMemoryCache memoryCache, + IDistributedCacheService distributedCacheService, IHttpContextAccessor httpContextAccessor, IStoreAccessControlRuleStatusService storeAccessControlRuleStatusService, IOrganizationService organizationService, @@ -76,7 +75,7 @@ public StoreAccessControlRuleService(IOptions appSettings, IExecutionStrategyService executionStrategyService) { _appSettings = appSettings.Value; - _memoryCache = memoryCache; + _distributedCacheService = distributedCacheService; _httpContextAccessor = httpContextAccessor; _storeAccessControlRuleStatusService = storeAccessControlRuleStatusService; _organizationService = organizationService; @@ -315,7 +314,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => if (result.Opportunities?.Count == 0) result.Opportunities = null; result.Opportunities = result.Opportunities?.OrderBy(o => o.Title).ToList(); - _memoryCache.Remove(CacheHelper.GenerateKey()); + _distributedCacheService.Remove(CacheHelper.GenerateKey()); return result; } @@ -410,7 +409,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () => if (result.Opportunities?.Count == 0) result.Opportunities = null; result.Opportunities = result.Opportunities?.OrderBy(o => o.Title).ToList(); - _memoryCache.Remove(CacheHelper.GenerateKey()); + _distributedCacheService.Remove(CacheHelper.GenerateKey()); return result; } @@ -449,7 +448,7 @@ public async Task UpdateStatus(Guid id, StoreAccessContr result = await _storeAccessControlRuleRepistory.Update(result); - _memoryCache.Remove(CacheHelper.GenerateKey()); + _distributedCacheService.Remove(CacheHelper.GenerateKey()); return result; } @@ -794,15 +793,19 @@ private async Task AssignOpportunities(List? opp #region Private Members private List RulesUpdatableCached() { - if (!_appSettings.CacheEnabledByCacheItemTypesAsEnum.HasFlag(Core.CacheItemType.Lookups)) + if (!_appSettings.CacheEnabledByCacheItemTypesAsEnum.HasFlag(CacheItemType.Lookups)) return Search(new StoreAccessControlRuleSearchFilter { NonPaginatedQuery = true, Statuses = [.. Statuses_Updatable] }, false).Items; - var result = _memoryCache.GetOrCreate(CacheHelper.GenerateKey(), entry => - { - entry.SlidingExpiration = TimeSpan.FromHours(_appSettings.CacheSlidingExpirationInHours); - entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(_appSettings.CacheAbsoluteExpirationRelativeToNowInDays); - return Search(new StoreAccessControlRuleSearchFilter { NonPaginatedQuery = true, Statuses = [.. Statuses_Updatable] }, false).Items; - }) ?? throw new InvalidOperationException($"Failed to retrieve cached list of '{nameof(StoreAccessControlRule)}s'"); + var result = _distributedCacheService.GetOrCreate( + CacheHelper.GenerateKey(), + () => Search(new StoreAccessControlRuleSearchFilter + { + NonPaginatedQuery = true, + Statuses = [.. Statuses_Updatable] + }, false).Items, + TimeSpan.FromHours(_appSettings.CacheSlidingExpirationInHours), + TimeSpan.FromDays(_appSettings.CacheAbsoluteExpirationRelativeToNowInDays) + ) ?? throw new InvalidOperationException($"Failed to retrieve cached list of '{nameof(StoreAccessControlRule)}s'"); return result; } diff --git a/src/api/src/domain/Yoma.Core.Domain/Startup.cs b/src/api/src/domain/Yoma.Core.Domain/Startup.cs index 94c7a590e..79aef096b 100644 --- a/src/api/src/domain/Yoma.Core.Domain/Startup.cs +++ b/src/api/src/domain/Yoma.Core.Domain/Startup.cs @@ -80,6 +80,7 @@ public static void ConfigureServices_DomainServices(this IServiceCollection serv #region Core services.AddScoped(); + services.AddScoped(); services.AddScoped(); #endregion Core