diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/OrganizationRegistry/NameRegistryClient.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/OrganizationRegistry/NameRegistryClient.cs index f1669d418..e8e385470 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/OrganizationRegistry/NameRegistryClient.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/OrganizationRegistry/NameRegistryClient.cs @@ -3,18 +3,18 @@ using System.Text.Json; using System.Text.Json.Serialization; using Digdir.Domain.Dialogporten.Application.Externals; -using Digdir.Domain.Dialogporten.Infrastructure.Common.Extensions; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; +using ZiggyCreatures.Caching.Fusion; -namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.OrganizationRegistry; +namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.NameRegistry; internal class NameRegistryClient : INameRegistry { private static readonly DistributedCacheEntryOptions _oneDayCacheDuration = new() { AbsoluteExpiration = DateTimeOffset.UtcNow.AddDays(1) }; private static readonly DistributedCacheEntryOptions _zeroCacheDuration = new() { AbsoluteExpiration = DateTimeOffset.MinValue }; - private readonly IDistributedCache _cache; + private readonly IFusionCache _cache; private readonly HttpClient _client; private readonly ILogger _logger; @@ -25,25 +25,21 @@ internal class NameRegistryClient : INameRegistry DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }; - public NameRegistryClient(HttpClient client, IDistributedCache cache, ILogger logger) + public NameRegistryClient(HttpClient client, IFusionCacheProvider cacheProvider, ILogger logger) { _client = client ?? throw new ArgumentNullException(nameof(client)); - _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _cache = cacheProvider.GetCache(nameof(NameRegistry)) ?? throw new ArgumentNullException(nameof(cacheProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task GetName(string personalIdentificationNumber, CancellationToken cancellationToken) { - return await _cache.GetOrAddAsync( + return await _cache.GetOrSetAsync( $"Name_{personalIdentificationNumber}", (ct) => GetNameFromRegister(personalIdentificationNumber, ct), - CacheOptionsFactory, - cancellationToken: cancellationToken); + token: cancellationToken); } - private static DistributedCacheEntryOptions CacheOptionsFactory(string? name) => - name is not null ? _oneDayCacheDuration : _zeroCacheDuration; - private async Task GetNameFromRegister(string personalIdentificationNumber, CancellationToken cancellationToken) { const string apiUrl = "register/api/v1/parties/nameslookup"; diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/OrganizationRegistry/OrganizationRegistryClient.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/OrganizationRegistry/OrganizationRegistryClient.cs index 8859672aa..8c5c4e349 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/OrganizationRegistry/OrganizationRegistryClient.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/OrganizationRegistry/OrganizationRegistryClient.cs @@ -1,8 +1,8 @@ using System.Diagnostics; using System.Net.Http.Json; using Digdir.Domain.Dialogporten.Application.Externals; -using Digdir.Domain.Dialogporten.Infrastructure.Common.Extensions; using Microsoft.Extensions.Caching.Distributed; +using ZiggyCreatures.Caching.Fusion; namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.OrganizationRegistry; @@ -12,28 +12,22 @@ internal class OrganizationRegistryClient : IOrganizationRegistry private static readonly DistributedCacheEntryOptions _oneDayCacheDuration = new() { AbsoluteExpiration = DateTimeOffset.UtcNow.AddDays(1) }; private static readonly DistributedCacheEntryOptions _zeroCacheDuration = new() { AbsoluteExpiration = DateTimeOffset.MinValue }; - private readonly IDistributedCache _cache; + private readonly IFusionCache _cache; private readonly HttpClient _client; - public OrganizationRegistryClient(HttpClient client, IDistributedCache cache) + public OrganizationRegistryClient(HttpClient client, IFusionCacheProvider cacheProvider) { _client = client ?? throw new ArgumentNullException(nameof(client)); - _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _cache = cacheProvider.GetCache(nameof(OrganizationRegistry)) ?? throw new ArgumentNullException(nameof(cacheProvider)); } public async Task GetOrgShortName(string orgNumber, CancellationToken cancellationToken) { - var orgShortNameByOrgNumber = await _cache.GetOrAddAsync( - OrgShortNameReferenceCacheKey, - GetOrgShortNameByOrgNumber, - CacheOptionsFactory, - cancellationToken: cancellationToken); + var orgShortNameByOrgNumber = await _cache.GetOrSetAsync(OrgShortNameReferenceCacheKey, async token => await GetOrgShortNameByOrgNumber(token), token: cancellationToken); orgShortNameByOrgNumber.TryGetValue(orgNumber, out var orgShortName); + return orgShortName; } - private static DistributedCacheEntryOptions? CacheOptionsFactory(Dictionary? orgShortNameByOrgNumber) => - orgShortNameByOrgNumber is not null ? _oneDayCacheDuration : _zeroCacheDuration; - private async Task> GetOrgShortNameByOrgNumber(CancellationToken cancellationToken) { const string searchEndpoint = "orgs/altinn-orgs.json"; diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/ResourceRegistry/ResourceRegistryClient.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/ResourceRegistry/ResourceRegistryClient.cs index c40a0cc43..0e9c81d5c 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/ResourceRegistry/ResourceRegistryClient.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/ResourceRegistry/ResourceRegistryClient.cs @@ -2,8 +2,8 @@ using System.Net.Http.Json; using Digdir.Domain.Dialogporten.Application.Externals; using Digdir.Domain.Dialogporten.Domain.Common; -using Digdir.Domain.Dialogporten.Infrastructure.Common.Extensions; using Microsoft.Extensions.Caching.Distributed; +using ZiggyCreatures.Caching.Fusion; namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.ResourceRegistry; @@ -13,29 +13,25 @@ internal sealed class ResourceRegistryClient : IResourceRegistry private static readonly DistributedCacheEntryOptions _oneDayCacheDuration = new() { AbsoluteExpiration = DateTimeOffset.UtcNow.AddDays(1) }; private static readonly DistributedCacheEntryOptions _zeroCacheDuration = new() { AbsoluteExpiration = DateTimeOffset.MinValue }; - private readonly IDistributedCache _cache; + private readonly IFusionCache _cache; private readonly HttpClient _client; - public ResourceRegistryClient(HttpClient client, IDistributedCache cache) + public ResourceRegistryClient(HttpClient client, IFusionCacheProvider cacheProvider) { _client = client ?? throw new ArgumentNullException(nameof(client)); - _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _cache = cacheProvider.GetCache(nameof(ResourceRegistry)) ?? throw new ArgumentNullException(nameof(cacheProvider)); } public async Task> GetResourceIds(string org, CancellationToken cancellationToken) { - var resourceIdsByOrg = await _cache.GetOrAddAsync( + var resourceIdsByOrg = await _cache.GetOrSetAsync( OrgResourceReferenceCacheKey, - GetResourceIdsByOrg, - CacheOptionsFactory, - cancellationToken: cancellationToken); + async token => await GetResourceIdsByOrg(token), + token: cancellationToken); resourceIdsByOrg.TryGetValue(org, out var resourceIds); return resourceIds ?? Array.Empty(); } - private static DistributedCacheEntryOptions? CacheOptionsFactory(Dictionary? resourceIdsByOrg) => - resourceIdsByOrg is not null ? _oneDayCacheDuration : _zeroCacheDuration; - private async Task> GetResourceIdsByOrg(CancellationToken cancellationToken) { const string searchEndpoint = "resourceregistry/api/v1/resource/search"; diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Common/Extensions/DistributedCacheExtensions.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Common/Extensions/DistributedCacheExtensions.cs deleted file mode 100644 index de38454a7..000000000 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Common/Extensions/DistributedCacheExtensions.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Collections.Concurrent; -using Microsoft.Extensions.Caching.Distributed; -using System.Text.Json; - -namespace Digdir.Domain.Dialogporten.Infrastructure.Common.Extensions; - -internal static class DistributedCacheExtensions -{ - private static readonly ConcurrentDictionary _locks = new(); - - /// - /// Gets the value associated with the specified key from the distributed cache, or adds it if not found. - /// - /// The type of value to retrieve or add. - /// The distributed cache instance. - /// The cache key to look up or add. - /// - /// A function that returns the value to be added to the cache if not found. - /// This function takes a CancellationToken as a parameter for asynchronous operations. - /// - /// - /// An optional function that provides cache entry options based on the generated value. - /// If not provided or the function returns null, default options will be used. - /// - /// - /// Optional. The JSON serialization options to use when serializing and deserializing the cache value. - /// - /// A CancellationToken that can be used to cancel the operation. - /// - /// The value associated with the specified key, either retrieved from the cache or added using the provided factory function. - /// If the value cannot be found in the cache or if the specified options indicate that the cached value has expired, - /// the provided factory function's result will be returned. - /// - /// - /// This method first attempts to retrieve the value associated with the given key from the distributed cache. - /// If the value is found, it is deserialized and returned. If not found or if the cache entry options indicate that the - /// cached value has expired, the provided valueFactory function is called to generate the value. The optionsFactory - /// function can be used to specify custom cache entry options based on the generated value, such as setting an absolute - /// expiration time or using sliding expiration. - /// - internal static async Task GetOrAddAsync( - this IDistributedCache cache, - string key, - Func> valueFactory, - Func? optionsFactory = null, - JsonSerializerOptions? jsonOptions = null, - CancellationToken cancellationToken = default) - { - var value = await TryGetFromCache(cache, key, jsonOptions, cancellationToken); - if (value is not null) - { - return value; - } - - var keyLock = _locks.GetOrAdd(key, new SemaphoreSlim(1, 1)); - await keyLock.WaitAsync(cancellationToken); - - try - { - // Try getting the value from cache again (it might have been added to cache by another task while waiting for lock). - value = await TryGetFromCache(cache, key, jsonOptions, cancellationToken); - if (value is not null) - { - return value; - } - - value = await valueFactory(cancellationToken); - var options = optionsFactory?.Invoke(value) ?? new(); - if (options.AbsoluteExpiration < DateTimeOffset.UtcNow) - { - return value; - } - - await cache.SetAsync(key, JsonSerializer.SerializeToUtf8Bytes(value), options, cancellationToken); - } - finally - { - keyLock.Release(); - } - return value; - } - - private static async Task TryGetFromCache(IDistributedCache cache, string key, JsonSerializerOptions? jsonOptions, CancellationToken cancellationToken) - { - var cachedValue = await cache.GetAsync(key, cancellationToken); - if (cachedValue is not null) - { - return JsonSerializer.Deserialize(cachedValue, jsonOptions); - } - return default; - } -} diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Digdir.Domain.Dialogporten.Infrastructure.csproj b/src/Digdir.Domain.Dialogporten.Infrastructure/Digdir.Domain.Dialogporten.Infrastructure.csproj index 9215ce58b..1036b5a8e 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Digdir.Domain.Dialogporten.Infrastructure.csproj +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Digdir.Domain.Dialogporten.Infrastructure.csproj @@ -22,13 +22,14 @@ + + + - - + + diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureExtensions.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureExtensions.cs index 26782fcff..32f1a7791 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureExtensions.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureExtensions.cs @@ -25,6 +25,14 @@ using Digdir.Domain.Dialogporten.Infrastructure.Altinn.Events; using Digdir.Domain.Dialogporten.Infrastructure.Altinn.OrganizationRegistry; using Digdir.Domain.Dialogporten.Infrastructure.Altinn.ResourceRegistry; +using System.Text.Json; +using Microsoft.Extensions.Caching.StackExchangeRedis; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Caching.Memory; +using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis; +using Digdir.Domain.Dialogporten.Infrastructure.Altinn.NameRegistry; namespace Digdir.Domain.Dialogporten.Infrastructure; @@ -56,24 +64,33 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi .AddValidatorsFromAssembly(thisAssembly, ServiceLifetime.Transient, includeInternalTypes: true); var infrastructureSettings = infrastructureConfigurationSection.Get() - ?? throw new InvalidOperationException("Failed to get Redis settings. Infrastructure settings must not be null."); + ?? throw new InvalidOperationException("Failed to get Redis settings. Infrastructure settings must not be null."); + + services.AddFusionCacheNeueccMessagePackSerializer(); if (infrastructureSettings.Redis.Enabled == true) { - services.AddStackExchangeRedisCache(options => - { - var infrastructureSettings = infrastructureConfigurationSection.Get() - ?? throw new InvalidOperationException("Failed to get Redis connection string. Infrastructure settings must not be null."); - var connectionString = infrastructureSettings.Redis.ConnectionString; - options.Configuration = connectionString; - options.InstanceName = "Redis"; - }); + services.AddStackExchangeRedisCache(opt => opt.Configuration = infrastructureSettings.Redis.ConnectionString); + services.AddFusionCacheStackExchangeRedisBackplane(opt => opt.Configuration = infrastructureSettings.Redis.ConnectionString); } else { services.AddDistributedMemoryCache(); } + services.ConfigureFusionCache(nameof(Altinn.NameRegistry), new() + { + Duration = TimeSpan.FromDays(1), + }) + .ConfigureFusionCache(nameof(Altinn.ResourceRegistry), new() + { + Duration = TimeSpan.FromMinutes(20), + }) + .ConfigureFusionCache(nameof(Altinn.OrganizationRegistry), new() + { + Duration = TimeSpan.FromDays(1), + }); + services.AddDbContext((services, options) => { var connectionString = services.GetRequiredService>() @@ -149,6 +166,21 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi return services; } + public class FusionCacheSettings + { + public TimeSpan Duration { get; set; } = TimeSpan.FromMinutes(1); + public TimeSpan FailSafeMaxDuration { get; set; } = TimeSpan.FromHours(2); + public TimeSpan FailSafeThrottleDuration { get; set; } = TimeSpan.FromSeconds(30); + public TimeSpan FactorySoftTimeout { get; set; } = TimeSpan.FromMilliseconds(100); + public TimeSpan FactoryHardTimeout { get; set; } = TimeSpan.FromMilliseconds(1500); + public TimeSpan DistributedCacheSoftTimeout { get; set; } = TimeSpan.FromSeconds(1); + public TimeSpan DistributedCacheHardTimeout { get; set; } = TimeSpan.FromSeconds(2); + public bool AllowBackgroundDistributedCacheOperations { get; set; } = true; + public bool IsFailSafeEnabled { get; set; } = true; + public TimeSpan JitterMaxDuration { get; set; } = TimeSpan.FromSeconds(2); + public float EagerRefreshThreshold { get; set; } = 0.8f; + } + private static IHttpClientBuilder AddMaskinportenHttpClient( this IServiceCollection services, IConfiguration configuration, @@ -163,4 +195,39 @@ private static IHttpClientBuilder AddMaskinportenHttpClient() .AddMaskinportenHttpMessageHandler(configureClientDefinition); } + + private static IServiceCollection ConfigureFusionCache(this IServiceCollection services, string cacheName, FusionCacheSettings? settings = null) + { + settings ??= new FusionCacheSettings(); + + services.AddFusionCache(cacheName) + .WithOptions(options => + { + options.DistributedCacheCircuitBreakerDuration = TimeSpan.FromSeconds(2); + }) + .WithDefaultEntryOptions(new FusionCacheEntryOptions + { + Duration = settings.Duration, + + IsFailSafeEnabled = settings.IsFailSafeEnabled, + FailSafeMaxDuration = settings.FailSafeMaxDuration, + FailSafeThrottleDuration = settings.FailSafeThrottleDuration, + + FactorySoftTimeout = settings.FactorySoftTimeout, + FactoryHardTimeout = settings.FactoryHardTimeout, + + DistributedCacheSoftTimeout = settings.DistributedCacheSoftTimeout, + DistributedCacheHardTimeout = settings.DistributedCacheHardTimeout, + + AllowBackgroundDistributedCacheOperations = settings.AllowBackgroundDistributedCacheOperations, + + JitterMaxDuration = settings.JitterMaxDuration, + EagerRefreshThreshold = settings.EagerRefreshThreshold + }) + .WithRegisteredSerializer() + .WithRegisteredDistributedCache() + .WithRegisteredBackplane(); + + return services; + } } diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureSettings.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureSettings.cs index 65be366ae..9d43f2967 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureSettings.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureSettings.cs @@ -94,6 +94,10 @@ internal sealed class RedisSettingsValidator : AbstractValidator public RedisSettingsValidator() { RuleFor(x => x.Enabled).Must(x => x is false or true); - RuleFor(x => x.ConnectionString).NotEmpty(); + + When(x => x.Enabled == true, () => + { + RuleFor(x => x.ConnectionString).NotEmpty(); + }); } } \ No newline at end of file diff --git a/src/Digdir.Domain.Dialogporten.Service/appsettings.Development.json b/src/Digdir.Domain.Dialogporten.Service/appsettings.Development.json index 35b72e314..00042fc37 100644 --- a/src/Digdir.Domain.Dialogporten.Service/appsettings.Development.json +++ b/src/Digdir.Domain.Dialogporten.Service/appsettings.Development.json @@ -11,6 +11,9 @@ "Password": "guest" }, "Infrastructure": { + "Redis": { + "Enabled": false + }, "DialogDbConnectionString": "TODO: Add to local secrets", // Settings from appsettings.json, environment variables or other configuration providers. // The first three are always mandatory for all client definitions types