diff --git a/src/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensions.cs b/src/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensions.cs
new file mode 100644
index 0000000000..427bc67d3f
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensions.cs
@@ -0,0 +1,62 @@
+using Microsoft.Extensions.Caching.Hybrid;
+
+namespace Umbraco.Cms.Infrastructure.HybridCache.Extensions;
+
+///
+/// Provides extension methods on .
+///
+internal static class HybridCacheExtensions
+{
+ ///
+ /// Returns true if the cache contains an item with a matching key.
+ ///
+ /// An instance of
+ /// The name (key) of the item to search for in the cache.
+ /// True if the item exists already. False if it doesn't.
+ ///
+ /// Hat-tip: https://github.com/dotnet/aspnetcore/discussions/57191
+ /// Will never add or alter the state of any items in the cache.
+ ///
+ public static async Task ExistsAsync(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key)
+ {
+ (bool exists, _) = await TryGetValueAsync(cache, key);
+ return exists;
+ }
+
+ ///
+ /// Returns true if the cache contains an item with a matching key, along with the value of the matching cache entry.
+ ///
+ /// The type of the value of the item in the cache.
+ /// An instance of
+ /// The name (key) of the item to search for in the cache.
+ /// A tuple of and the object (if found) retrieved from the cache.
+ ///
+ /// Hat-tip: https://github.com/dotnet/aspnetcore/discussions/57191
+ /// Will never add or alter the state of any items in the cache.
+ ///
+ public static async Task<(bool Exists, T? Value)> TryGetValueAsync(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key)
+ {
+ var exists = true;
+
+ T? result = await cache.GetOrCreateAsync(
+ key,
+ null!,
+ (_, _) =>
+ {
+ exists = false;
+ return new ValueTask(default(T)!);
+ },
+ new HybridCacheEntryOptions(),
+ null,
+ CancellationToken.None);
+
+ // In checking for the existence of the item, if not found, we will have created a cache entry with a null value.
+ // So remove it again.
+ if (exists is false)
+ {
+ await cache.RemoveAsync(key);
+ }
+
+ return (exists, result);
+ }
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs b/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs
index 91f8ad8e92..b72af50a59 100644
--- a/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs
+++ b/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs
@@ -146,6 +146,26 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
return CreateContentNodeKit(dto, serializer, preview);
}
+ public async Task> GetContentSourcesAsync(IEnumerable keys, bool preview = false)
+ {
+ Sql? sql = SqlContentSourcesSelect()
+ .Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document))
+ .WhereIn(x => x.UniqueId, keys)
+ .Append(SqlOrderByLevelIdSortOrder(SqlContext));
+
+ List dtos = await Database.FetchAsync(sql);
+
+ dtos = dtos
+ .Where(x => x is not null)
+ .Where(x => preview || x.PubDataRaw is not null || x.PubData is not null)
+ .ToList();
+
+ IContentCacheDataSerializer serializer =
+ _contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document);
+ return dtos
+ .Select(x => CreateContentNodeKit(x, serializer, preview));
+ }
+
private IEnumerable GetContentSourceByDocumentTypeKey(IEnumerable documentTypeKeys, Guid objectType)
{
Guid[] keys = documentTypeKeys.ToArray();
@@ -220,6 +240,25 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
return CreateMediaNodeKit(dto, serializer);
}
+ public async Task> GetMediaSourcesAsync(IEnumerable keys)
+ {
+ Sql? sql = SqlMediaSourcesSelect()
+ .Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Media))
+ .WhereIn(x => x.UniqueId, keys)
+ .Append(SqlOrderByLevelIdSortOrder(SqlContext));
+
+ List dtos = await Database.FetchAsync(sql);
+
+ dtos = dtos
+ .Where(x => x is not null)
+ .ToList();
+
+ IContentCacheDataSerializer serializer =
+ _contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Media);
+ return dtos
+ .Select(x => CreateMediaNodeKit(x, serializer));
+ }
+
private async Task OnRepositoryRefreshed(IContentCacheDataSerializer serializer, ContentCacheNode content, bool preview)
{
// use a custom SQL to update row version on each update
diff --git a/src/Umbraco.PublishedCache.HybridCache/Persistence/IDatabaseCacheRepository.cs b/src/Umbraco.PublishedCache.HybridCache/Persistence/IDatabaseCacheRepository.cs
index 21a9e8cfbc..a10a616fdc 100644
--- a/src/Umbraco.PublishedCache.HybridCache/Persistence/IDatabaseCacheRepository.cs
+++ b/src/Umbraco.PublishedCache.HybridCache/Persistence/IDatabaseCacheRepository.cs
@@ -5,48 +5,96 @@ namespace Umbraco.Cms.Infrastructure.HybridCache.Persistence;
internal interface IDatabaseCacheRepository
{
+ ///
+ /// Deletes the specified content item from the cache database.
+ ///
Task DeleteContentItemAsync(int id);
+ ///
+ /// Gets a single cache node for a document key.
+ ///
Task GetContentSourceAsync(Guid key, bool preview = false);
+ ///
+ /// Gets a collection of cache nodes for a collection of document keys.
+ ///
+ // TODO (V18): Remove the default implementation on this method.
+ async Task> GetContentSourcesAsync(IEnumerable keys, bool preview = false)
+ {
+ var contentCacheNodes = new List();
+ foreach (Guid key in keys)
+ {
+ ContentCacheNode? contentSource = await GetContentSourceAsync(key, preview);
+ if (contentSource is not null)
+ {
+ contentCacheNodes.Add(contentSource);
+ }
+ }
+
+ return contentCacheNodes;
+ }
+
+ ///
+ /// Gets a single cache node for a media key.
+ ///
Task GetMediaSourceAsync(Guid key);
+ ///
+ /// Gets a collection of cache nodes for a collection of media keys.
+ ///
+ // TODO (V18): Remove the default implementation on this method.
+ async Task> GetMediaSourcesAsync(IEnumerable keys)
+ {
+ var contentCacheNodes = new List();
+ foreach (Guid key in keys)
+ {
+ ContentCacheNode? contentSource = await GetMediaSourceAsync(key);
+ if (contentSource is not null)
+ {
+ contentCacheNodes.Add(contentSource);
+ }
+ }
+ return contentCacheNodes;
+ }
+
+ ///
+ /// Gets a collection of cache nodes for a collection of content type keys and entity type.
+ ///
IEnumerable GetContentByContentTypeKey(IEnumerable keys, ContentCacheDataSerializerEntityType entityType);
///
- /// Gets all content keys of specific document types
+ /// Gets all content keys of specific document types.
///
/// The document types to find content using.
+ /// A flag indicating whether to restrict to just published content.
/// The keys of all content use specific document types.
IEnumerable GetDocumentKeysByContentTypeKeys(IEnumerable keys, bool published = false);
///
- /// Refreshes the nucache database row for the given cache node />
+ /// Refreshes the cache for the given document cache node.
///
- /// A representing the asynchronous operation.
Task RefreshContentAsync(ContentCacheNode contentCacheNode, PublishedState publishedState);
///
- /// Refreshes the nucache database row for the given cache node />
+ /// Refreshes the cache row for the given media cache node.
///
- /// A representing the asynchronous operation.
Task RefreshMediaAsync(ContentCacheNode contentCacheNode);
///
- /// Rebuilds the caches for content, media and/or members based on the content type ids specified
+ /// Rebuilds the caches for content, media and/or members based on the content type ids specified.
///
///
/// If not null will process content for the matching content types, if empty will process all
- /// content
+ /// content.
///
///
/// If not null will process content for the matching media types, if empty will process all
- /// media
+ /// media.
///
///
/// If not null will process content for the matching members types, if empty will process all
- /// members
+ /// members.
///
void Rebuild(
IReadOnlyCollection? contentTypeIds = null,
diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs
index d083739b45..1e68acad7f 100644
--- a/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs
+++ b/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs
@@ -1,4 +1,8 @@
+#if DEBUG
+ using System.Diagnostics;
+#endif
using Microsoft.Extensions.Caching.Hybrid;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
@@ -7,6 +11,7 @@ using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Navigation;
+using Umbraco.Cms.Infrastructure.HybridCache.Extensions;
using Umbraco.Cms.Infrastructure.HybridCache.Factories;
using Umbraco.Cms.Infrastructure.HybridCache.Persistence;
using Umbraco.Cms.Infrastructure.HybridCache.Serialization;
@@ -26,8 +31,8 @@ internal sealed class DocumentCacheService : IDocumentCacheService
private readonly IPublishedModelFactory _publishedModelFactory;
private readonly IPreviewService _previewService;
private readonly IPublishStatusQueryService _publishStatusQueryService;
- private readonly IDocumentNavigationQueryService _documentNavigationQueryService;
private readonly CacheSettings _cacheSettings;
+ private readonly ILogger _logger;
private HashSet? _seedKeys;
private HashSet SeedKeys
@@ -62,7 +67,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService
IPublishedModelFactory publishedModelFactory,
IPreviewService previewService,
IPublishStatusQueryService publishStatusQueryService,
- IDocumentNavigationQueryService documentNavigationQueryService)
+ ILogger logger)
{
_databaseCacheRepository = databaseCacheRepository;
_idKeyMap = idKeyMap;
@@ -74,8 +79,8 @@ internal sealed class DocumentCacheService : IDocumentCacheService
_publishedModelFactory = publishedModelFactory;
_previewService = previewService;
_publishStatusQueryService = publishStatusQueryService;
- _documentNavigationQueryService = documentNavigationQueryService;
_cacheSettings = cacheSettings.Value;
+ _logger = logger;
}
public async Task GetByKeyAsync(Guid key, bool? preview = null)
@@ -185,44 +190,64 @@ internal sealed class DocumentCacheService : IDocumentCacheService
public async Task SeedAsync(CancellationToken cancellationToken)
{
- foreach (Guid key in SeedKeys)
+#if DEBUG
+ var sw = new Stopwatch();
+ sw.Start();
+#endif
+
+ const int GroupSize = 100;
+ foreach (IEnumerable group in SeedKeys.InGroupsOf(GroupSize))
{
- if (cancellationToken.IsCancellationRequested)
+ var uncachedKeys = new HashSet();
+ foreach (Guid key in group)
{
- break;
+ if (cancellationToken.IsCancellationRequested)
+ {
+ break;
+ }
+
+ var cacheKey = GetCacheKey(key, false);
+
+ var existsInCache = await _hybridCache.ExistsAsync(cacheKey);
+ if (existsInCache is false)
+ {
+ uncachedKeys.Add(key);
+ }
}
- var cacheKey = GetCacheKey(key, false);
+ _logger.LogDebug("Uncached key count {KeyCount}", uncachedKeys.Count);
- // We'll use GetOrCreateAsync because it may be in the second level cache, in which case we don't have to re-seed.
- ContentCacheNode? cachedValue = await _hybridCache.GetOrCreateAsync(
- cacheKey,
- async cancel =>
- {
- using ICoreScope scope = _scopeProvider.CreateCoreScope();
-
- ContentCacheNode? cacheNode = await _databaseCacheRepository.GetContentSourceAsync(key);
-
- scope.Complete();
-
- // We don't want to seed drafts
- if (cacheNode is null || cacheNode.IsDraft)
- {
- return null;
- }
-
- return cacheNode;
- },
- GetSeedEntryOptions(),
- GenerateTags(key),
- cancellationToken: cancellationToken);
-
- // If the value is null, it's likely because
- if (cachedValue is null)
+ if (uncachedKeys.Count == 0)
{
- await _hybridCache.RemoveAsync(cacheKey, cancellationToken);
+ continue;
+ }
+
+ using ICoreScope scope = _scopeProvider.CreateCoreScope();
+
+ IEnumerable cacheNodes = await _databaseCacheRepository.GetContentSourcesAsync(uncachedKeys);
+
+ scope.Complete();
+
+ _logger.LogDebug("Document nodes to cache {NodeCount}", cacheNodes.Count());
+
+ foreach (ContentCacheNode cacheNode in cacheNodes)
+ {
+ var cacheKey = GetCacheKey(cacheNode.Key, false);
+ await _hybridCache.SetAsync(
+ cacheKey,
+ cacheNode,
+ GetSeedEntryOptions(),
+ GenerateTags(cacheNode.Key),
+ cancellationToken: cancellationToken);
}
}
+
+#if DEBUG
+ sw.Stop();
+ _logger.LogInformation("Document cache seeding completed in {ElapsedMilliseconds} ms with {SeedCount} seed keys.", sw.ElapsedMilliseconds, SeedKeys.Count);
+#else
+ _logger.LogInformation("Document cache seeding completed with {SeedCount} seed keys.", SeedKeys.Count);
+#endif
}
// Internal for test purposes.
@@ -256,16 +281,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService
return false;
}
- ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync(
- GetCacheKey(keyAttempt.Result, preview), // Unique key to the cache entry
- cancel => ValueTask.FromResult(null));
-
- if (contentCacheNode is null)
- {
- await _hybridCache.RemoveAsync(GetCacheKey(keyAttempt.Result, preview));
- }
-
- return contentCacheNode is not null;
+ return await _hybridCache.ExistsAsync(GetCacheKey(keyAttempt.Result, preview));
}
public async Task RefreshContentAsync(IContent content)
diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs
index 4359f824c8..ca9691a1c4 100644
--- a/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs
+++ b/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs
@@ -1,4 +1,8 @@
+#if DEBUG
+ using System.Diagnostics;
+#endif
using Microsoft.Extensions.Caching.Hybrid;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
@@ -6,6 +10,7 @@ using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
+using Umbraco.Cms.Infrastructure.HybridCache.Extensions;
using Umbraco.Cms.Infrastructure.HybridCache.Factories;
using Umbraco.Cms.Infrastructure.HybridCache.Persistence;
using Umbraco.Cms.Infrastructure.HybridCache.Serialization;
@@ -23,6 +28,7 @@ internal sealed class MediaCacheService : IMediaCacheService
private readonly ICacheNodeFactory _cacheNodeFactory;
private readonly IEnumerable _seedKeyProviders;
private readonly IPublishedModelFactory _publishedModelFactory;
+ private readonly ILogger _logger;
private readonly CacheSettings _cacheSettings;
private HashSet? _seedKeys;
@@ -55,7 +61,8 @@ internal sealed class MediaCacheService : IMediaCacheService
ICacheNodeFactory cacheNodeFactory,
IEnumerable seedKeyProviders,
IPublishedModelFactory publishedModelFactory,
- IOptions cacheSettings)
+ IOptions cacheSettings,
+ ILogger logger)
{
_databaseCacheRepository = databaseCacheRepository;
_idKeyMap = idKeyMap;
@@ -66,6 +73,7 @@ internal sealed class MediaCacheService : IMediaCacheService
_seedKeyProviders = seedKeyProviders;
_publishedModelFactory = publishedModelFactory;
_cacheSettings = cacheSettings.Value;
+ _logger = logger;
}
public async Task GetByKeyAsync(Guid key)
@@ -125,19 +133,9 @@ internal sealed class MediaCacheService : IMediaCacheService
return false;
}
- ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync(
- $"{keyAttempt.Result}", // Unique key to the cache entry
- cancel => ValueTask.FromResult(null));
-
- if (contentCacheNode is null)
- {
- await _hybridCache.RemoveAsync($"{keyAttempt.Result}");
- }
-
- return contentCacheNode is not null;
+ return await _hybridCache.ExistsAsync($"{keyAttempt.Result}");
}
-
public async Task RefreshMediaAsync(IMedia media)
{
using ICoreScope scope = _scopeProvider.CreateCoreScope();
@@ -155,33 +153,64 @@ internal sealed class MediaCacheService : IMediaCacheService
public async Task SeedAsync(CancellationToken cancellationToken)
{
- foreach (Guid key in SeedKeys)
+#if DEBUG
+ var sw = new Stopwatch();
+ sw.Start();
+#endif
+
+ const int GroupSize = 100;
+ foreach (IEnumerable group in SeedKeys.InGroupsOf(GroupSize))
{
- if (cancellationToken.IsCancellationRequested)
+ var uncachedKeys = new HashSet();
+ foreach (Guid key in group)
{
- break;
+ if (cancellationToken.IsCancellationRequested)
+ {
+ break;
+ }
+
+ var cacheKey = GetCacheKey(key, false);
+
+ var existsInCache = await _hybridCache.ExistsAsync(cacheKey);
+ if (existsInCache is false)
+ {
+ uncachedKeys.Add(key);
+ }
}
- var cacheKey = GetCacheKey(key, false);
+ _logger.LogDebug("Uncached key count {KeyCount}", uncachedKeys.Count);
- ContentCacheNode? cachedValue = await _hybridCache.GetOrCreateAsync(
- cacheKey,
- async cancel =>
- {
- using ICoreScope scope = _scopeProvider.CreateCoreScope();
- ContentCacheNode? mediaCacheNode = await _databaseCacheRepository.GetMediaSourceAsync(key);
- scope.Complete();
- return mediaCacheNode;
- },
- GetSeedEntryOptions(),
- GenerateTags(key),
- cancellationToken: cancellationToken);
-
- if (cachedValue is null)
+ if (uncachedKeys.Count == 0)
{
- await _hybridCache.RemoveAsync(cacheKey);
+ continue;
+ }
+
+ using ICoreScope scope = _scopeProvider.CreateCoreScope();
+
+ IEnumerable cacheNodes = await _databaseCacheRepository.GetMediaSourcesAsync(uncachedKeys);
+
+ scope.Complete();
+
+ _logger.LogDebug("Media nodes to cache {NodeCount}", cacheNodes.Count());
+
+ foreach (ContentCacheNode cacheNode in cacheNodes)
+ {
+ var cacheKey = GetCacheKey(cacheNode.Key, false);
+ await _hybridCache.SetAsync(
+ cacheKey,
+ cacheNode,
+ GetSeedEntryOptions(),
+ GenerateTags(cacheNode.Key),
+ cancellationToken: cancellationToken);
}
}
+
+#if DEBUG
+ sw.Stop();
+ _logger.LogInformation("Media cache seeding completed in {ElapsedMilliseconds} ms with {SeedCount} seed keys.", sw.ElapsedMilliseconds, SeedKeys.Count);
+#else
+ _logger.LogInformation("Media cache seeding completed with {SeedCount} seed keys.", SeedKeys.Count);
+#endif
}
public async Task RefreshMemoryCacheAsync(Guid key)
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheMockTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheMockTests.cs
index e6ba41cefc..1b68301380 100644
--- a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheMockTests.cs
+++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheMockTests.cs
@@ -1,3 +1,4 @@
+using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using NUnit.Framework;
@@ -26,8 +27,8 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache;
internal sealed class DocumentHybridCacheMockTests : UmbracoIntegrationTestWithContent
{
private IPublishedContentCache _mockedCache;
- private Mock _mockedNucacheRepository;
- private IDocumentCacheService _mockDocumentCacheService;
+ private Mock _mockDatabaseCacheRepository;
+ private IDocumentCacheService _documentCacheService;
protected override void CustomTestSetup(IUmbracoBuilder builder) => builder.AddUmbracoHybridCache();
@@ -38,7 +39,7 @@ internal sealed class DocumentHybridCacheMockTests : UmbracoIntegrationTestWithC
[SetUp]
public void SetUp()
{
- _mockedNucacheRepository = new Mock();
+ _mockDatabaseCacheRepository = new Mock();
var contentData = new ContentData(
Textpage.Name,
@@ -76,25 +77,29 @@ internal sealed class DocumentHybridCacheMockTests : UmbracoIntegrationTestWithC
IsDraft = false,
};
- _mockedNucacheRepository.Setup(r => r.GetContentSourceAsync(It.IsAny(), true))
+ _mockDatabaseCacheRepository.Setup(r => r.GetContentSourceAsync(It.IsAny(), true))
.ReturnsAsync(draftTestCacheNode);
+ _mockDatabaseCacheRepository.Setup(r => r.GetContentSourcesAsync(It.IsAny>(), true))
+ .ReturnsAsync([draftTestCacheNode]);
- _mockedNucacheRepository.Setup(r => r.GetContentSourceAsync(It.IsAny(), false))
+ _mockDatabaseCacheRepository.Setup(r => r.GetContentSourceAsync(It.IsAny(), false))
.ReturnsAsync(publishedTestCacheNode);
+ _mockDatabaseCacheRepository.Setup(r => r.GetContentSourcesAsync(It.IsAny>(), false))
+ .ReturnsAsync([publishedTestCacheNode]);
- _mockedNucacheRepository.Setup(r => r.GetContentByContentTypeKey(It.IsAny>(), ContentCacheDataSerializerEntityType.Document)).Returns(
+ _mockDatabaseCacheRepository.Setup(r => r.GetContentByContentTypeKey(It.IsAny>(), ContentCacheDataSerializerEntityType.Document)).Returns(
new List()
{
draftTestCacheNode,
});
- _mockedNucacheRepository.Setup(r => r.DeleteContentItemAsync(It.IsAny()));
+ _mockDatabaseCacheRepository.Setup(r => r.DeleteContentItemAsync(It.IsAny()));
var mockedPublishedStatusService = new Mock();
mockedPublishedStatusService.Setup(x => x.IsDocumentPublishedInAnyCulture(It.IsAny())).Returns(true);
- _mockDocumentCacheService = new DocumentCacheService(
- _mockedNucacheRepository.Object,
+ _documentCacheService = new DocumentCacheService(
+ _mockDatabaseCacheRepository.Object,
GetRequiredService(),
GetRequiredService(),
GetRequiredService(),
@@ -105,9 +110,10 @@ internal sealed class DocumentHybridCacheMockTests : UmbracoIntegrationTestWithC
GetRequiredService(),
GetRequiredService(),
mockedPublishedStatusService.Object,
- GetRequiredService());
+ new NullLogger());
- _mockedCache = new DocumentCache(_mockDocumentCacheService,
+ _mockedCache = new DocumentCache(
+ _documentCacheService,
GetRequiredService(),
GetRequiredService(),
GetRequiredService(),
@@ -118,8 +124,10 @@ internal sealed class DocumentHybridCacheMockTests : UmbracoIntegrationTestWithC
// So we'll manually create them with a magic options mock.
private IEnumerable GetSeedProviders(IPublishStatusQueryService publishStatusQueryService)
{
- _cacheSettings = new CacheSettings();
- _cacheSettings.DocumentBreadthFirstSeedCount = 0;
+ _cacheSettings = new CacheSettings
+ {
+ DocumentBreadthFirstSeedCount = 0
+ };
var mock = new Mock>();
mock.Setup(m => m.Value).Returns(() => _cacheSettings);
@@ -140,7 +148,7 @@ internal sealed class DocumentHybridCacheMockTests : UmbracoIntegrationTestWithC
var textPage2 = await _mockedCache.GetByIdAsync(Textpage.Key, true);
AssertTextPage(textPage);
AssertTextPage(textPage2);
- _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1));
+ _mockDatabaseCacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1));
}
[Test]
@@ -152,79 +160,79 @@ internal sealed class DocumentHybridCacheMockTests : UmbracoIntegrationTestWithC
var textPage2 = await _mockedCache.GetByIdAsync(Textpage.Id, true);
AssertTextPage(textPage);
AssertTextPage(textPage2);
- _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1));
+ _mockDatabaseCacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1));
}
[Test]
public async Task Content_Is_Seeded_By_Id()
{
- var schedule = new CultureAndScheduleModel
+ var schedule = new CulturePublishScheduleModel
{
- CulturesToPublishImmediately = new HashSet { "*" }, Schedules = new ContentScheduleCollection(),
+ Culture = Constants.System.InvariantCulture,
};
- var publishResult = await ContentPublishingService.PublishAsync(Textpage.Key, schedule, Constants.Security.SuperUserKey);
+ var publishResult = await ContentPublishingService.PublishAsync(Textpage.Key, [schedule], Constants.Security.SuperUserKey);
Assert.IsTrue(publishResult.Success);
Textpage.Published = true;
- await _mockDocumentCacheService.DeleteItemAsync(Textpage);
+ await _documentCacheService.DeleteItemAsync(Textpage);
_cacheSettings.ContentTypeKeys = [ Textpage.ContentType.Key ];
- await _mockDocumentCacheService.SeedAsync(CancellationToken.None);
- _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1));
+ await _documentCacheService.SeedAsync(CancellationToken.None);
+ _mockDatabaseCacheRepository.Verify(x => x.GetContentSourcesAsync(It.IsAny>(), It.IsAny()), Times.Exactly(1));
var textPage = await _mockedCache.GetByIdAsync(Textpage.Id);
AssertTextPage(textPage);
- _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1));
+ _mockDatabaseCacheRepository.Verify(x => x.GetContentSourcesAsync(It.IsAny>(), It.IsAny()), Times.Exactly(1));
}
[Test]
public async Task Content_Is_Seeded_By_Key()
{
- var schedule = new CultureAndScheduleModel
+ var schedule = new CulturePublishScheduleModel
{
- CulturesToPublishImmediately = new HashSet { "*" }, Schedules = new ContentScheduleCollection(),
+ Culture = Constants.System.InvariantCulture,
};
- var publishResult = await ContentPublishingService.PublishAsync(Textpage.Key, schedule, Constants.Security.SuperUserKey);
+ var publishResult = await ContentPublishingService.PublishAsync(Textpage.Key, [schedule], Constants.Security.SuperUserKey);
Assert.IsTrue(publishResult.Success);
Textpage.Published = true;
- await _mockDocumentCacheService.DeleteItemAsync(Textpage);
+ await _documentCacheService.DeleteItemAsync(Textpage);
_cacheSettings.ContentTypeKeys = [ Textpage.ContentType.Key ];
- await _mockDocumentCacheService.SeedAsync(CancellationToken.None);
- _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1));
+ await _documentCacheService.SeedAsync(CancellationToken.None);
+ _mockDatabaseCacheRepository.Verify(x => x.GetContentSourcesAsync(It.IsAny>(), It.IsAny()), Times.Exactly(1));
var textPage = await _mockedCache.GetByIdAsync(Textpage.Key);
AssertTextPage(textPage);
- _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1));
+ _mockDatabaseCacheRepository.Verify(x => x.GetContentSourcesAsync(It.IsAny>(), It.IsAny()), Times.Exactly(1));
}
[Test]
public async Task Content_Is_Not_Seeded_If_Unpblished_By_Id()
{
- await _mockDocumentCacheService.DeleteItemAsync(Textpage);
+ await _documentCacheService.DeleteItemAsync(Textpage);
_cacheSettings.ContentTypeKeys = [ Textpage.ContentType.Key ];
- await _mockDocumentCacheService.SeedAsync(CancellationToken.None);
+ await _documentCacheService.SeedAsync(CancellationToken.None);
var textPage = await _mockedCache.GetByIdAsync(Textpage.Id, true);
AssertTextPage(textPage);
- _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1));
+ _mockDatabaseCacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1));
}
[Test]
public async Task Content_Is_Not_Seeded_If_Unpublished_By_Key()
{
_cacheSettings.ContentTypeKeys = [ Textpage.ContentType.Key ];
- await _mockDocumentCacheService.DeleteItemAsync(Textpage);
+ await _documentCacheService.DeleteItemAsync(Textpage);
- await _mockDocumentCacheService.SeedAsync(CancellationToken.None);
+ await _documentCacheService.SeedAsync(CancellationToken.None);
var textPage = await _mockedCache.GetByIdAsync(Textpage.Key, true);
AssertTextPage(textPage);
- _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1));
+ _mockDatabaseCacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1));
}
private void AssertTextPage(IPublishedContent textPage)
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/DocumentBreadthFirstKeyProviderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/DocumentBreadthFirstKeyProviderTests.cs
index beab657044..649a161f13 100644
--- a/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/DocumentBreadthFirstKeyProviderTests.cs
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/DocumentBreadthFirstKeyProviderTests.cs
@@ -1,4 +1,4 @@
-using Microsoft.Extensions.Options;
+using Microsoft.Extensions.Options;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Models;
@@ -6,6 +6,7 @@ using Umbraco.Cms.Core.Services.Navigation;
using Umbraco.Cms.Infrastructure.HybridCache.SeedKeyProviders.Document;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.PublishedCache.HybridCache;
+
[TestFixture]
public class DocumentBreadthFirstKeyProviderTests
{
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensionsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensionsTests.cs
new file mode 100644
index 0000000000..27e0cb0d0a
--- /dev/null
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensionsTests.cs
@@ -0,0 +1,186 @@
+using Microsoft.Extensions.Caching.Hybrid;
+using Moq;
+using NUnit.Framework;
+using Umbraco.Cms.Infrastructure.HybridCache.Extensions;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.PublishedCache.HybridCache.Extensions;
+
+///
+/// Provides tests to cover the class.
+///
+///
+/// Hat-tip: https://github.com/dotnet/aspnetcore/discussions/57191
+///
+[TestFixture]
+public class HybridCacheExtensionsTests
+{
+ private Mock _cacheMock;
+
+ [SetUp]
+ public void TestInitialize()
+ {
+ _cacheMock = new Mock();
+ }
+
+ [Test]
+ public async Task ExistsAsync_WhenKeyExists_ShouldReturnTrue()
+ {
+ // Arrange
+ string key = "test-key";
+ var expectedValue = "test-value";
+
+ _cacheMock
+ .Setup(cache => cache.GetOrCreateAsync(
+ key,
+ null!,
+ It.IsAny>>(),
+ It.IsAny(),
+ null,
+ CancellationToken.None))
+ .ReturnsAsync(expectedValue);
+
+ // Act
+ var exists = await HybridCacheExtensions.ExistsAsync(_cacheMock.Object, key);
+
+ // Assert
+ Assert.IsTrue(exists);
+ }
+
+ [Test]
+ public async Task ExistsAsync_WhenKeyDoesNotExist_ShouldReturnFalse()
+ {
+ // Arrange
+ string key = "test-key";
+
+ _cacheMock
+ .Setup(cache => cache.GetOrCreateAsync(
+ key,
+ null!,
+ It.IsAny>>(),
+ It.IsAny(),
+ null,
+ CancellationToken.None))
+ .Returns((
+ string key,
+ object? state,
+ Func> factory,
+ HybridCacheEntryOptions? options,
+ IEnumerable? tags,
+ CancellationToken token) =>
+ {
+ return factory(state!, token);
+ });
+
+ // Act
+ var exists = await HybridCacheExtensions.ExistsAsync(_cacheMock.Object, key);
+
+ // Assert
+ Assert.IsFalse(exists);
+ }
+
+ [Test]
+ public async Task TryGetValueAsync_WhenKeyExists_ShouldReturnTrueAndValueAsString()
+ {
+ // Arrange
+ string key = "test-key";
+ var expectedValue = "test-value";
+
+ _cacheMock
+ .Setup(cache => cache.GetOrCreateAsync(
+ key,
+ null!,
+ It.IsAny>>(),
+ It.IsAny(),
+ null,
+ CancellationToken.None))
+ .ReturnsAsync(expectedValue);
+
+ // Act
+ var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key);
+
+ // Assert
+ Assert.IsTrue(exists);
+ Assert.AreEqual(expectedValue, value);
+ }
+
+ [Test]
+ public async Task TryGetValueAsync_WhenKeyExists_ShouldReturnTrueAndValueAsInteger()
+ {
+ // Arrange
+ string key = "test-key";
+ var expectedValue = 5;
+
+ _cacheMock
+ .Setup(cache => cache.GetOrCreateAsync(
+ key,
+ null!,
+ It.IsAny>>(),
+ It.IsAny(),
+ null,
+ CancellationToken.None))
+ .ReturnsAsync(expectedValue);
+
+ // Act
+ var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key);
+
+ // Assert
+ Assert.IsTrue(exists);
+ Assert.AreEqual(expectedValue, value);
+ }
+
+ [Test]
+ public async Task TryGetValueAsync_WhenKeyExistsButValueIsNull_ShouldReturnTrueAndNullValue()
+ {
+ // Arrange
+ string key = "test-key";
+
+ _cacheMock
+ .Setup(cache => cache.GetOrCreateAsync(
+ key,
+ null!,
+ It.IsAny>>(),
+ It.IsAny(),
+ null,
+ CancellationToken.None))
+ .ReturnsAsync(null!);
+
+ // Act
+ var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key);
+
+ // Assert
+ Assert.IsTrue(exists);
+ Assert.IsNull(value);
+ }
+
+ [Test]
+ public async Task TryGetValueAsync_WhenKeyDoesNotExist_ShouldReturnFalseAndNull()
+ {
+ // Arrange
+ string key = "test-key";
+
+ _cacheMock.Setup(cache => cache.GetOrCreateAsync(
+ key,
+ null,
+ It.IsAny>>(),
+ It.IsAny(),
+ null,
+ CancellationToken.None))
+ .Returns((
+ string key,
+ object? state,
+ Func> factory,
+ HybridCacheEntryOptions? options,
+ IEnumerable? tags,
+ CancellationToken token) =>
+ {
+ return factory(state, token);
+ });
+
+ // Act
+ var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key);
+
+ // Assert
+ Assert.IsFalse(exists);
+ Assert.IsNull(value);
+ }
+}