Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions Dan.Common/Extensions/DistributedCacheExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System.Text;
using Microsoft.Extensions.Caching.Distributed;

namespace Dan.Common.Extensions;

/// <summary>
/// Extension methods for IDistributedCache (i.e Redis)
/// </summary>
public static class DistributedCacheExtensions
{
/// <summary>
/// Gets value from distributed cache by key and deserializes into POCO
/// </summary>
/// <typeparam name="T">Type to deserialize into</typeparam>
public static async Task<T?> GetValueAsync<T>(this IDistributedCache distributedCache, string key)
{
var encodedPoco = await distributedCache.GetAsync(key);
if (encodedPoco == null)
{
return default;
}
var serializedPoco = Encoding.UTF8.GetString(encodedPoco);
return JsonConvert.DeserializeObject<T>(serializedPoco);
}

/// <summary>
/// Serializes and sets value in distributed cache
/// </summary>
public static async Task SetValueAsync<T>(this IDistributedCache distributedCache, string key, T value, DistributedCacheEntryOptions? options = null)
{
options ??= new DistributedCacheEntryOptions();
var serializedValue = JsonConvert.SerializeObject(value);
var encodedValue = Encoding.UTF8.GetBytes(serializedValue);
await distributedCache.SetAsync(key, encodedValue, options);
}
}
6 changes: 5 additions & 1 deletion Dan.Core.UnitTest/AvailableEvidenceCodesServiceTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,12 @@ public async Task CheckServiceContextRequirementsIncluded()
{

// Arrange
var mockCache = new MockCache();
var acs = new AvailableEvidenceCodesService(
_loggerFactory,
_mockHttpClientFactory.Object,
_policyRegistry,
mockCache,
_mockServiceContextService.Object,
_mockFunctionContextAccessor.Object);

Expand Down Expand Up @@ -179,10 +181,12 @@ public async Task CheckServiceContextRequirementsIncluded()
[TestMethod]
public async Task GetAliases()
{
var mockCache = new MockCache();
var acs = new AvailableEvidenceCodesService(
_loggerFactory,
_mockHttpClientFactory.Object,
_policyRegistry,
mockCache,
_mockServiceContextService.Object,
_mockFunctionContextAccessor.Object);

Expand All @@ -194,7 +198,7 @@ public async Task GetAliases()

// Act
await acs.GetAvailableEvidenceCodes();
var actual = acs.GetAliases();
var actual = await acs.GetAliases();

// Assert
actual.Should().BeEquivalentTo(expected);
Expand Down
61 changes: 61 additions & 0 deletions Dan.Core.UnitTest/Helpers/MockCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System.Text;
using Microsoft.Extensions.Caching.Distributed;
using Newtonsoft.Json;

namespace Dan.Core.UnitTest.Helpers;

public class MockCache : IDistributedCache
{
private Dictionary<string, byte[]> _backingStore;

public MockCache()
{
_backingStore = new Dictionary<string, byte[]>();
}

public byte[] Get(string key)
{
return Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(_backingStore[key]));
}

public async Task<byte[]?> GetAsync(string key, CancellationToken token = new())

Check warning on line 21 in Dan.Core.UnitTest/Helpers/MockCache.cs

View workflow job for this annotation

GitHub Actions / build-and-test

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

Check warning on line 21 in Dan.Core.UnitTest/Helpers/MockCache.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
{
if (!_backingStore.ContainsKey(key))
{
return default;
}
return await Task.FromResult(_backingStore[key]);
}

public void Set(string key, byte[] value, DistributedCacheEntryOptions options)
{
_backingStore[key] = value;
}

public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options,
CancellationToken token = new())
{
_backingStore[key] = value;
await Task.CompletedTask;
}

public void Refresh(string key)
{
throw new NotImplementedException();
}

public Task RefreshAsync(string key, CancellationToken token = new())
{
throw new NotImplementedException();
}

public void Remove(string key)
{
throw new NotImplementedException();
}

public Task RemoveAsync(string key, CancellationToken token = new())
{
throw new NotImplementedException();
}
}
2 changes: 1 addition & 1 deletion Dan.Core/FuncAuthorization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public async Task<HttpResponseData> RunAsync(
using (var t = _logger.Timer($"{evidenceCode.EvidenceCodeName}-init"))
{
_logger.LogInformation("Start init async evidenceCode={evidenceCode} aid={accreditationId}", evidenceCode.EvidenceCodeName, accreditation.AccreditationId);
var aliases = _availableEvidenceCodesService.GetAliases();
var aliases = await _availableEvidenceCodesService.GetAliases();
await EvidenceSourceHelper.InitAsynchronousEvidenceCodeRequest(accreditation, evidenceCode, _client, aliases);
_logger.LogInformation("Completed init async evidenceCode={evidenceCode} aid={accreditationId} elapsedMs={elapsedMs}", evidenceCode.EvidenceCodeName, accreditation.AccreditationId, t.ElapsedMilliseconds);
}
Expand Down
96 changes: 33 additions & 63 deletions Dan.Core/Services/AvailableEvidenceCodesService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Dan.Common.Models;
using Dan.Common.Extensions;
using Dan.Core.Config;
using Dan.Core.Extensions;
using Dan.Core.Services.Interfaces;
Expand All @@ -11,24 +12,23 @@
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
using AsyncKeyedLock;
using Azure.Identity;

namespace Dan.Core.Services;

public class AvailableEvidenceCodesService(
ILoggerFactory loggerFactory,
IHttpClientFactory httpClientFactory,
IPolicyRegistry<string> policyRegistry,

Check warning on line 22 in Dan.Core/Services/AvailableEvidenceCodesService.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Parameter 'policyRegistry' is unread.

Check warning on line 22 in Dan.Core/Services/AvailableEvidenceCodesService.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Parameter 'policyRegistry' is unread.
IDistributedCache distributedCache,
IServiceContextService serviceContextService,
IFunctionContextAccessor functionContextAccessor)
: IAvailableEvidenceCodesService
{
public static TimeSpan DistributedCacheTtl = TimeSpan.FromHours(12);
private readonly ILogger<IAvailableEvidenceCodesService> _logger = loggerFactory.CreateLogger<AvailableEvidenceCodesService>();

private List<EvidenceCode> _memoryCache = [];
private DateTime _updateMemoryCache = DateTime.MinValue;
private readonly AsyncNonKeyedLocker _semaphoreForceRefresh = new(1);
private readonly AsyncNonKeyedLocker _semaphore = new(1);
private readonly AsyncNonKeyedLocker semaphore = new(1);
private const int MemoryCacheTtlSeconds = 600;

private const string CachingPolicy = "EvidenceCodesCachePolicy";
Expand All @@ -37,53 +37,51 @@
private const string CacheResponseHeader = "x-cache";

/// <summary>
/// Gets the list of current active evidence codes. This endpoint can be hit several times during a request. In order to reduce I/O to the distributed cache, it employs
/// an additional layer of caching via memory. Uses semaphores to handle concurrent writes to the caches.
/// Gets the list of current active evidence codes. This endpoint can be hit several times during a request.
/// </summary>
/// <param name="forceRefresh">If true will evict the current cache (both in-memory and distributed) and force a source-level refresh</param>
/// <returns>A list of active evidence codes</returns>
public async Task<List<EvidenceCode>> GetAvailableEvidenceCodes(bool forceRefresh = false)
{
// Cache still valid
if (!forceRefresh && DateTime.UtcNow < _updateMemoryCache)
List<EvidenceCode>? evidenceCodes;
if (!forceRefresh)
{
SetCacheDiagnosticsHeader("hit-local");
return FilterEvidenceCodes(_memoryCache);
evidenceCodes = await distributedCache.GetValueAsync<List<EvidenceCode>>(CacheContextKey);
if (evidenceCodes is not null)
{
SetCacheDiagnosticsHeader("hit-distributed");
return evidenceCodes;
}
}

if (forceRefresh)
{
// Force refresh has been called. This is only performed manually or in conjuction with a deploy.
// Use a separate semaphore to ensure only a single thread can do this at a time without blocking other requests
using (await _semaphoreForceRefresh.LockAsync())
{
await RefreshEvidenceCodesCache();
return FilterEvidenceCodes(_memoryCache);
}
SetCacheDiagnosticsHeader("force-evict");
}

// The memory cache is expired. We do not know if Redis cache is expired, as this is handled by Polly.
using (await _semaphore.LockAsync())
using (await semaphore.LockAsync())
{
// Recheck if another thread has updated the memory cache while we were waiting for the semaphore
if (DateTime.UtcNow < _updateMemoryCache)
if (!forceRefresh)
{
SetCacheDiagnosticsHeader("hit-local-late");
return FilterEvidenceCodes(_memoryCache);
// Checking if another thread finished caching
evidenceCodes = await distributedCache.GetValueAsync<List<EvidenceCode>>(CacheContextKey);
if (evidenceCodes is not null)
{
return evidenceCodes;
}
}

// This uses Polly to get from the distributed cache, or refresh from source if Redis cache is expired.
_memoryCache = await GetAvailableEvidenceCodesFromDistributedCache();
_updateMemoryCache = DateTime.UtcNow.AddSeconds(MemoryCacheTtlSeconds);

return FilterEvidenceCodes(_memoryCache);
evidenceCodes = await GetAvailableEvidenceCodesFromEvidenceSources();
await distributedCache.SetValueAsync(CacheContextKey, evidenceCodes);
evidenceCodes = FilterEvidenceCodes(evidenceCodes);
return evidenceCodes;
}
}

public Dictionary<string, string> GetAliases()
public async Task<Dictionary<string, string>> GetAliases()
{
var aliases = new Dictionary<string, string>();
var aliasedEvidenceCodes = _memoryCache
var availableEvienceCodes = await GetAvailableEvidenceCodes();
var aliasedEvidenceCodes = availableEvienceCodes
.Where(ec => ec.DatasetAliases is not null && ec.DatasetAliases.Count > 0);
foreach (var aliasedEvidenceCode in aliasedEvidenceCodes)
{
Expand Down Expand Up @@ -111,38 +109,6 @@

}

/// <summary>
/// This fetches evidence codes from the sources and updates the distributed and in-memory caches.
/// </summary>
/// <returns>Nothing</returns>
private async Task RefreshEvidenceCodesCache()
{
var evidenceCodes = await GetAvailableEvidenceCodesFromEvidenceSources();
SetCacheDiagnosticsHeader("force-evict");
if (evidenceCodes.Count == 0)
{
_logger.LogWarning("Failed to refresh evidence codes cache, received empty list");
return;
}

// Add some metadata properties to make serialized output more parseable
foreach (var es in evidenceCodes)
{
es.AuthorizationRequirements.ForEach(x => x.RequirementType = x.GetType().Name);
}

_memoryCache = evidenceCodes;
_updateMemoryCache = DateTime.UtcNow.AddSeconds(MemoryCacheTtlSeconds);
}

private async Task<List<EvidenceCode>> GetAvailableEvidenceCodesFromDistributedCache()
{
SetCacheDiagnosticsHeader("hit-distributed");
var cachePolicy = policyRegistry.Get<AsyncPolicy<List<EvidenceCode>>>(CachingPolicy);
return await cachePolicy.ExecuteAsync(
async _ => await GetAvailableEvidenceCodesFromEvidenceSources(), new Context(CacheContextKey));
}

private async Task<List<EvidenceCode>> GetAvailableEvidenceCodesFromEvidenceSources()
{
SetCacheDiagnosticsHeader("miss", overwrite: true);
Expand Down Expand Up @@ -225,6 +191,10 @@
{
evidenceCodes = FilterInactive(evidenceCodes);
evidenceCodes = SplitAliases(evidenceCodes);
foreach (var es in evidenceCodes)
{
es.AuthorizationRequirements.ForEach(x => x.RequirementType = x.GetType().Name);
}
return evidenceCodes.ToList();
}
private static List<EvidenceCode> FilterInactive(IEnumerable<EvidenceCode> evidenceCodes)
Expand Down
4 changes: 2 additions & 2 deletions Dan.Core/Services/EvidenceHarvesterService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ public async Task<Evidence> HarvestOpenData(EvidenceCode evidenceCode, string id
{
_log.LogDebug("Running HaaS (Harvest as a Service) for open data with dataset {evidenceCodeName} and identifier {identifier}", evidenceCode.EvidenceCodeName, identifier == "" ? "(empty)" : identifier);
List<EvidenceValue> harvestedEvidence;
var aliases = _availableEvidenceCodesService.GetAliases();
var aliases = await _availableEvidenceCodesService.GetAliases();
var url = evidenceCode.GetEvidenceSourceUrl(aliases);

var request = new HttpRequestMessage(HttpMethod.Post, url);
Expand Down Expand Up @@ -191,7 +191,7 @@ private async Task<List<EvidenceValue>> HarvestEvidenceValues(EvidenceCode evide
private async Task<HttpRequestMessage> GetEvidenceHarvesterRequestMessage(Accreditation accreditation,
EvidenceCode evidenceCode, EvidenceHarvesterOptions? evidenceHarvesterOptions = default)
{
var aliases = _availableEvidenceCodesService.GetAliases();
var aliases = await _availableEvidenceCodesService.GetAliases();
var url = evidenceCode.GetEvidenceSourceUrl(aliases);

var request = new HttpRequestMessage(HttpMethod.Post, url);
Expand Down
2 changes: 1 addition & 1 deletion Dan.Core/Services/EvidenceStatusService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ private async Task DetermineAggregateStatus(Accreditation accreditation, bool on

private async Task<EvidenceStatusCode> GetAsynchronousEvidenceStatusCode(Accreditation accreditation, EvidenceCode evidenceCode)
{
var aliases = _availableEvidenceCodesService.GetAliases();
var aliases = await _availableEvidenceCodesService.GetAliases();
var url = evidenceCode.GetEvidenceSourceUrl(aliases);

var request = new HttpRequestMessage(HttpMethod.Post, url);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ public interface IAvailableEvidenceCodesService
{
public Task<List<EvidenceCode>> GetAvailableEvidenceCodes(bool forceRefresh = false);

public Dictionary<string, string> GetAliases();
public Task<Dictionary<string, string>> GetAliases();
}
Loading