Skip to content

Commit

Permalink
Merge pull request #853 from DFE-Digital/feat/234422/initial-redis-in…
Browse files Browse the repository at this point in the history
…tegration

feat: Initial redis integration
  • Loading branch information
katie-gardner authored Nov 6, 2024
2 parents b57ffa8 + d092f4f commit b0f77b6
Show file tree
Hide file tree
Showing 38 changed files with 1,789 additions and 36 deletions.
28 changes: 23 additions & 5 deletions plan-technology-for-your-school.sln
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,24 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dfe.PlanTech.UnitTests.Shar
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dfe.PlanTech.Infrastructure.Data.UnitTests", "tests\Dfe.PlanTech.Infrastructure.Data.UnitTests\Dfe.PlanTech.Infrastructure.Data.UnitTests.csproj", "{B82ED911-FFCD-4EFF-BD9E-405394811F13}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dfe.PlanTech.Web.SeedTestData", "tests\Dfe.PlanTech.Web.SeedTestData\Dfe.PlanTech.Web.SeedTestData.csproj", "{0E05B7CD-6CDC-41EE-89BB-AE415A359085}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dfe.PlanTech.Domain.UnitTests", "tests\Dfe.PlanTech.Domain.UnitTests\Dfe.PlanTech.Domain.UnitTests.csproj", "{199DF118-68E9-42D6-ABB2-47FCC9BFD515}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dfe.PlanTech.Domain.UnitTests", "tests\Dfe.PlanTech.Domain.UnitTests\Dfe.PlanTech.Domain.UnitTests.csproj", "{199DF118-68E9-42D6-ABB2-47FCC9BFD515}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dfe.PlanTech.CmsDbDataValidator", "tests\Dfe.PlanTech.CmsDbDataValidator\Dfe.PlanTech.CmsDbDataValidator.csproj", "{110402D5-93DC-40B9-8CA8-4BCE1B3CCCBC}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dfe.PlanTech.CmsDbDataValidator", "tests\Dfe.PlanTech.CmsDbDataValidator\Dfe.PlanTech.CmsDbDataValidator.csproj", "{110402D5-93DC-40B9-8CA8-4BCE1B3CCCBC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dfe.PlanTech.Infrastructure.ServiceBus.UnitTests", "tests\Dfe.PlanTech.Infrastructure.ServiceBus.UnitTests\Dfe.PlanTech.Infrastructure.ServiceBus.UnitTests.csproj", "{6A2698E9-2AB0-4CDB-A070-FA2DB4CA9DD4}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dfe.PlanTech.Infrastructure.ServiceBus.UnitTests", "tests\Dfe.PlanTech.Infrastructure.ServiceBus.UnitTests\Dfe.PlanTech.Infrastructure.ServiceBus.UnitTests.csproj", "{6A2698E9-2AB0-4CDB-A070-FA2DB4CA9DD4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dfe.PlanTech.Infrastructure.ServiceBus", "src\Dfe.PlanTech.Infrastructure.ServiceBus\Dfe.PlanTech.Infrastructure.ServiceBus.csproj", "{D12F6316-D1C1-4CB2-A6B4-9062E215DB00}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dfe.ContentSupport.Web.Tests", "tests\Dfe.ContentSupport.Web.Tests\Dfe.ContentSupport.Web.Tests.csproj", "{D26549E3-3A75-45EF-A229-7C8EFAFD7293}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dfe.ContentSupport.Web", "src\Dfe.ContentSupport.Web\Dfe.ContentSupport.Web.csproj", "{8DDA038E-1E6D-405D-B65C-C3DC6CDD68DD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dfe.PlanTech.Infrastructure.Redis", "src\Dfe.PlanTech.Infrastructure.Redis\Dfe.PlanTech.Infrastructure.Redis.csproj", "{9EA3BFC3-78D3-4E76-92C1-62BAC921158C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dfe.PlanTech.Web.SeedTestData", "tests\Dfe.PlanTech.Web.SeedTestData\Dfe.PlanTech.Web.SeedTestData.csproj", "{0E05B7CD-6CDC-41EE-89BB-AE415A359085}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dfe.PlanTech.Infrastructure.Redis.UnitTests", "tests\Dfe.PlanTech.Infrastructure.Redis.UnitTests\Dfe.PlanTech.Infrastructure.Redis.UnitTests.csproj", "{A96209F5-F5B2-4168-9CAB-FA98E8BDEABD}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -99,6 +103,10 @@ Global
{B82ED911-FFCD-4EFF-BD9E-405394811F13}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B82ED911-FFCD-4EFF-BD9E-405394811F13}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B82ED911-FFCD-4EFF-BD9E-405394811F13}.Release|Any CPU.Build.0 = Release|Any CPU
{902F3AA9-FF2C-4D89-98F2-74467B043BA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{902F3AA9-FF2C-4D89-98F2-74467B043BA0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{902F3AA9-FF2C-4D89-98F2-74467B043BA0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{902F3AA9-FF2C-4D89-98F2-74467B043BA0}.Release|Any CPU.Build.0 = Release|Any CPU
{0E05B7CD-6CDC-41EE-89BB-AE415A359085}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0E05B7CD-6CDC-41EE-89BB-AE415A359085}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0E05B7CD-6CDC-41EE-89BB-AE415A359085}.Release|Any CPU.ActiveCfg = Release|Any CPU
Expand Down Expand Up @@ -127,6 +135,14 @@ Global
{8DDA038E-1E6D-405D-B65C-C3DC6CDD68DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8DDA038E-1E6D-405D-B65C-C3DC6CDD68DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8DDA038E-1E6D-405D-B65C-C3DC6CDD68DD}.Release|Any CPU.Build.0 = Release|Any CPU
{9EA3BFC3-78D3-4E76-92C1-62BAC921158C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9EA3BFC3-78D3-4E76-92C1-62BAC921158C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9EA3BFC3-78D3-4E76-92C1-62BAC921158C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9EA3BFC3-78D3-4E76-92C1-62BAC921158C}.Release|Any CPU.Build.0 = Release|Any CPU
{A96209F5-F5B2-4168-9CAB-FA98E8BDEABD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A96209F5-F5B2-4168-9CAB-FA98E8BDEABD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A96209F5-F5B2-4168-9CAB-FA98E8BDEABD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A96209F5-F5B2-4168-9CAB-FA98E8BDEABD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -150,6 +166,8 @@ Global
{D12F6316-D1C1-4CB2-A6B4-9062E215DB00} = {20FD2D0C-81CA-4337-B00D-9CB0BB36FE46}
{D26549E3-3A75-45EF-A229-7C8EFAFD7293} = {33DA2E55-0921-4BEA-A7F4-96CC77F09928}
{8DDA038E-1E6D-405D-B65C-C3DC6CDD68DD} = {20FD2D0C-81CA-4337-B00D-9CB0BB36FE46}
{9EA3BFC3-78D3-4E76-92C1-62BAC921158C} = {20FD2D0C-81CA-4337-B00D-9CB0BB36FE46}
{A96209F5-F5B2-4168-9CAB-FA98E8BDEABD} = {33DA2E55-0921-4BEA-A7F4-96CC77F09928}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {2661FAF0-D2EA-4265-846E-E6AB213C854C}
Expand Down
100 changes: 100 additions & 0 deletions src/Dfe.PlanTech.Application/Caching/Interfaces/IDistributedCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
namespace Dfe.PlanTech.Application.Caching.Interfaces;

/// <summary>
/// Represents a distributed cache interface for managing cache items.
/// </summary>
public interface IDistributedCache
{
/// <summary>
/// Gets or creates a cache item asynchronously.
/// </summary>
/// <typeparam name="T">The type of the cache item.</typeparam>
/// <param name="key">The key of the cache item.</param>
/// <param name="action">The function to create the cache item if it does not exist.</param>
/// <param name="expiry">The optional expiration time for the cache item.</param>
/// <param name="onCacheItemCreation">An optional callback to invoke after the cache item is created.</param>
/// <param name="databaseId">The optional database identifier.</param>
/// <returns>The cached item or default value if not found.</returns>
Task<T?> GetOrCreateAsync<T>(string key, Func<Task<T>> action, TimeSpan? expiry = null, Func<T, Task>? onCacheItemCreation = null, int databaseId = -1);

/// <summary>
/// Sets a cache item asynchronously.
/// </summary>
/// <typeparam name="T">The type of the cache item.</typeparam>
/// <param name="key">The key of the cache item.</param>
/// <param name="value">The value of the cache item.</param>
/// <param name="expiry">The optional expiration time for the cache item.</param>
/// <param name="databaseId">The optional database identifier.</param>
/// <returns>The key of the cache item.</returns>
Task<string> SetAsync<T>(string key, T value, TimeSpan? expiry = null, int databaseId = -1);

/// <summary>
/// Removes a cache item asynchronously.
/// </summary>
/// <param name="key">The key of the cache item.</param>
/// <param name="databaseId">The optional database identifier.</param>
/// <returns>True if the item was removed; otherwise, false.</returns>
Task<bool> RemoveAsync(string key, int databaseId = -1);

/// <summary>
/// Removes multiple cache items asynchronously.
/// </summary>
/// <param name="keys">The keys of the cache items.</param>
Task RemoveAsync(params string[] keys);

/// <summary>
/// Removes multiple cache items from a specified database asynchronously.
/// </summary>
/// <param name="databaseId">The database identifier.</param>
/// <param name="keys">The keys of the cache items.</param>
Task RemoveAsync(int databaseId, params string[] keys);

/// <summary>
/// Appends an item to a cache entry asynchronously.
/// </summary>
/// <param name="key">The key of the cache item.</param>
/// <param name="item">The item to append.</param>
/// <param name="databaseId">The optional database identifier.</param>
Task AppendAsync(string key, string item, int databaseId = -1);

/// <summary>
/// Retrieves a cache item asynchronously.
/// </summary>
/// <typeparam name="T">The type of the cache item.</typeparam>
/// <param name="key">The key of the cache item.</param>
/// <param name="databaseId">The optional database identifier.</param>
/// <returns>The cached item or default value if not found.</returns>
Task<T?> GetAsync<T>(string key, int databaseId = -1);

/// <summary>
/// Adds an item to a set in the cache asynchronously.
/// </summary>
/// <param name="key">The key of the set.</param>
/// <param name="item">The item to add.</param>
/// <param name="databaseId">The optional database identifier.</param>
Task SetAddAsync(string key, string item, int databaseId = -1);

/// <summary>
/// Gets the members of a set from the cache asynchronously.
/// </summary>
/// <param name="key">The key of the set.</param>
/// <param name="databaseId">The optional database identifier.</param>
/// <returns>An array of set members.</returns>
Task<string[]> GetSetMembersAsync(string key, int databaseId = -1);

/// <summary>
/// Removes an item from a set in the cache asynchronously.
/// </summary>
/// <param name="key">The key of the set.</param>
/// <param name="item">The item to remove.</param>
/// <param name="databaseId">The optional database identifier.</param>
Task SetRemoveAsync(string key, string item, int databaseId = -1);

/// <summary>
/// Removes multiple items from a set in the cache asynchronously.
/// </summary>
/// <param name="key">The key of the set.</param>
/// <param name="items">The items to remove.</param>
/// <param name="databaseId">The optional database identifier.</param>
Task SetRemoveItemsAsync(string key, string[] items, int databaseId = -1);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
namespace Dfe.PlanTech.Application.Caching.Interfaces;

/// <summary>
/// Provides distributed locking functionality
/// </summary>
public interface IDistributedLockProvider
{
/// <summary>
/// Releases the lock for the specified key and lock value
/// </summary>
/// <param name="key">The key associated with the lock.</param>
/// <param name="lockValue">The lock value used to acquire the lock.</param>
/// <param name="databaseId">The Redis database ID to use. Defaults to -1, which means using the default database.</param>
/// <returns>True if the lock was successfully released; otherwise, false.</returns>
Task<bool> LockReleaseAsync(string key, string lockValue, int databaseId = -1);

/// <summary>
/// Extends the expiration of a distributed lock for the specified key and lock value.
/// </summary>
/// <param name="key">The key associated with the lock.</param>
/// <param name="lockValue">The lock value used to acquire the lock.</param>
/// <param name="duration">The new duration to set for the lock's expiration.</param>
/// <param name="databaseId">The Redis database ID to use. Defaults to -1, which means using the default database.</param>
/// <returns>True if the lock was successfully extended; otherwise, false.</returns>
Task<bool> LockExtendAsync(string key, string lockValue, TimeSpan duration, int databaseId = -1);

/// <summary>
/// Waits for a distributed lock to be acquired.
/// </summary>
/// <param name="key">The key associated with the lock.</param>
/// <param name="throwExceptionIfLockNotAcquired">Indicates whether an exception should be thrown if the lock is not acquired.</param>
/// <returns>The lock value if the lock was acquired; otherwise, null.</returns>
Task<string?> WaitForLockAsync(string key, bool throwExceptionIfLockNotAcquired = true);

/// <summary>
/// Executes an operation with a distributed lock.
/// </summary>
/// <param name="key">The key associated with the lock.</param>
/// <param name="runWithLock">The action to execute while holding the lock.</param>
/// <param name="databaseId">The Redis database ID to use. Defaults to -1, which means using the default database.</param>
Task LockAndRun(string key, Func<Task> runWithLock, int databaseId = -1);

/// <summary>
/// Executes an operation with a distributed lock and returns the result.
/// </summary>
/// <param name="key">The key associated with the lock.</param>
/// <param name="runWithLock">The function to execute while holding the lock.</param>
/// <param name="databaseId">The Redis database ID to use. Defaults to -1, which means using the default database.</param>
/// <returns>The result of the operation if the lock was acquired; otherwise, the default value.</returns>
Task<T?> LockAndGet<T>(string key, Func<Task<T>> runWithLock, int databaseId = -1);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,11 @@

namespace Dfe.PlanTech.Application.Content.Queries;

public class GetPageFromContentfulQuery : IGetPageQuery
public class GetPageFromContentfulQuery(
IContentRepository repository,
ILogger<GetPageFromContentfulQuery> logger,
GetPageFromContentfulOptions options) : IGetPageQuery
{
private readonly IContentRepository _repository;
private readonly ILogger<GetPageFromContentfulQuery> _logger;
private readonly GetPageFromContentfulOptions _options;

public GetPageFromContentfulQuery(IContentRepository repository, ILogger<GetPageFromContentfulQuery> logger, GetPageFromContentfulOptions options)
{
_repository = repository;
_logger = logger;
_options = options;
}

/// <summary>
/// Retrieves the page for the given slug from Contentful
/// </summary>
Expand All @@ -32,9 +24,9 @@ public GetPageFromContentfulQuery(IContentRepository repository, ILogger<GetPage
/// <exception cref="ContentfulDataUnavailableException"></exception>
public async Task<Page?> GetPageBySlug(string slug, CancellationToken cancellationToken = default)
{
var options = CreateGetEntityOptions(slug);
var queryOptions = CreateGetEntityOptions(slug);

return await FetchFromContentful(slug, options, cancellationToken);
return await FetchFromContentful(slug, queryOptions, cancellationToken);
}

/// <summary>
Expand All @@ -48,30 +40,30 @@ public GetPageFromContentfulQuery(IContentRepository repository, ILogger<GetPage

public async Task<Page?> GetPageBySlug(string slug, IEnumerable<string> fieldsToReturn, CancellationToken cancellationToken = default)
{
var options = CreateGetEntityOptions(slug);
options.Select = fieldsToReturn;
var queryOptions = CreateGetEntityOptions(slug);
queryOptions.Select = fieldsToReturn;

return await FetchFromContentful(slug, options, cancellationToken);
return await FetchFromContentful(slug, queryOptions, cancellationToken);
}

/// <exception cref="ContentfulDataUnavailableException"></exception>
private async Task<Page?> FetchFromContentful(string slug, GetEntitiesOptions options, CancellationToken cancellationToken)
{
try
{
var pages = await _repository.GetEntities<Page?>(options, cancellationToken);
var pages = await repository.GetEntities<Page?>(options, cancellationToken);

var page = pages.FirstOrDefault();

return page;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching page {slug} from Contentful", slug);
logger.LogError(ex, "Error fetching page {slug} from Contentful", slug);
throw new ContentfulDataUnavailableException($"Could not retrieve page with slug {slug}", ex);
}
}

private GetEntitiesOptions CreateGetEntityOptions(string slug) =>
new(_options.Include, new[] { new ContentQueryEquals() { Field = "fields.slug", Value = slug } });
new(options.Include, [new ContentQueryEquals() { Field = "fields.slug", Value = slug }]);
}
5 changes: 4 additions & 1 deletion src/Dfe.PlanTech.Application/Content/Queries/GetPageQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ public class GetPageQuery(GetPageFromContentfulQuery getPageFromContentfulQuery,
/// <returns>Page matching slug</returns>
public async Task<Page?> GetPageBySlug(string slug, CancellationToken cancellationToken = default)
{
var page = await getPageFromDbQuery.GetPageBySlug(slug, cancellationToken) ?? await getPageFromContentfulQuery.GetPageBySlug(slug, cancellationToken);
var page = await GetPage(slug, cancellationToken);

return page;
}

private async Task<Page?> GetPage(string slug, CancellationToken cancellationToken)
=> await getPageFromDbQuery.GetPageBySlug(slug, cancellationToken) ?? await getPageFromContentfulQuery.GetPageBySlug(slug, cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ namespace Dfe.PlanTech.Application.Content.Queries;

public class GetRichTextsForPageQuery(ICmsDbContext db, ILogger<GetRichTextsForPageQuery> logger, ContentfulOptions contentfulOptions) : IGetPageChildrenQuery
{
private readonly ICmsDbContext _db = db;
private readonly ContentfulOptions _contentfulOptions = contentfulOptions;

/// <summary>
/// Load RichTextContents for the given page from database
/// </summary>
Expand All @@ -27,9 +24,9 @@ public async Task TryLoadChildren(PageDbEntity page, CancellationToken cancellat
if (richTextParents.Length == 0)
return;

var richTextContentQuery = _db.RichTextContentWithSlugs.Where(PageMatchesSlugAndPublishedOrIsPreview(page));
var richTextContentQuery = db.RichTextContentWithSlugs.Where(PageMatchesSlugAndPublishedOrIsPreview(page));

var richTexts = await _db.ToListCachedAsync(richTextContentQuery, cancellationToken);
var richTexts = await db.ToListCachedAsync(richTextContentQuery, cancellationToken);

foreach (var parent in richTextParents)
{
Expand All @@ -53,6 +50,6 @@ public async Task TryLoadChildren(PageDbEntity page, CancellationToken cancellat

private Expression<Func<RichTextContentWithSlugDbEntity, bool>> PageMatchesSlugAndPublishedOrIsPreview(PageDbEntity page)
{
return content => content.Slug == page.Slug && (_contentfulOptions.UsePreview || content.Published);
return content => content.Slug == page.Slug && (contentfulOptions.UsePreview || content.Published);
}
}
4 changes: 2 additions & 2 deletions src/Dfe.PlanTech.Application/Dfe.PlanTech.Application.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
Expand All @@ -16,4 +16,4 @@
<ItemGroup>
<ProjectReference Include="..\Dfe.PlanTech.Domain\Dfe.PlanTech.Domain.csproj" />
</ItemGroup>
</Project>
</Project>
6 changes: 6 additions & 0 deletions src/Dfe.PlanTech.Domain/Caching/Exceptions/GuardException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Dfe.PlanTech.Domain.Caching.Exceptions;

public class GuardException(string message) : Exception(message)
{

}
6 changes: 6 additions & 0 deletions src/Dfe.PlanTech.Domain/Caching/Models/CacheKey.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Dfe.PlanTech.Domain.Caching.Models;

internal static class CacheKey
{
public static string Make(string name, string subname, params object[] items) => $"{name}.{subname}({string.Join(',', items.Select(x => x?.ToString()))})";
}
3 changes: 3 additions & 0 deletions src/Dfe.PlanTech.Domain/Caching/Models/CacheResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace Dfe.PlanTech.Domain.Caching.Models;

public record CacheResult<T>(bool? ExistedInCache = null, T? CacheValue = default, string? Error = null);
Loading

0 comments on commit b0f77b6

Please sign in to comment.